Phase 8: REPL, printing, source maps, and nREPL server
- IPrintWithWriter protocol + CljElixir.Printer module with pr-str, print-str, pr, prn for all BEAM types (EDN-like output) - Source-mapped error messages: line/col metadata from reader now propagated through transformer into Elixir AST for accurate error locations in .clje files - Interactive REPL (mix clje.repl) with multi-line input detection, history, bindings persistence, and pr-str formatted output - nREPL server (mix clje.nrepl) with TCP transport, Bencode wire protocol, session management, and core operations (clone, close, eval, describe, ls-sessions, load-file, interrupt, completions). Writes .nrepl-port for editor auto-discovery. 92 new tests (699 total, 0 failures). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
defmodule CljElixir.NReplTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias CljElixir.NRepl.{Bencode, Server, SessionManager, Handler}
|
||||
|
||||
# --- Bencode Tests ---
|
||||
|
||||
describe "Bencode encoding" do
|
||||
test "encode string" do
|
||||
assert Bencode.encode("hello") == "5:hello"
|
||||
assert Bencode.encode("") == "0:"
|
||||
end
|
||||
|
||||
test "encode integer" do
|
||||
assert Bencode.encode(42) == "i42e"
|
||||
assert Bencode.encode(0) == "i0e"
|
||||
assert Bencode.encode(-7) == "i-7e"
|
||||
end
|
||||
|
||||
test "encode list" do
|
||||
assert Bencode.encode([1, 2, 3]) == "li1ei2ei3ee"
|
||||
assert Bencode.encode([]) == "le"
|
||||
assert Bencode.encode(["hello", 42]) == "l5:helloi42ee"
|
||||
end
|
||||
|
||||
test "encode dict" do
|
||||
assert Bencode.encode(%{"op" => "eval"}) == "d2:op4:evale"
|
||||
end
|
||||
|
||||
test "encode nested" do
|
||||
msg = %{"op" => "eval", "code" => "(+ 1 2)", "id" => "1"}
|
||||
encoded = Bencode.encode(msg)
|
||||
assert is_binary(encoded)
|
||||
assert String.starts_with?(encoded, "d")
|
||||
assert String.ends_with?(encoded, "e")
|
||||
end
|
||||
|
||||
test "encode atom keys" do
|
||||
assert Bencode.encode(%{op: "eval"}) == "d2:op4:evale"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Bencode decoding" do
|
||||
test "decode string" do
|
||||
assert Bencode.decode("5:hello") == "hello"
|
||||
assert Bencode.decode("0:") == ""
|
||||
end
|
||||
|
||||
test "decode integer" do
|
||||
assert Bencode.decode("i42e") == 42
|
||||
assert Bencode.decode("i-7e") == -7
|
||||
end
|
||||
|
||||
test "decode list" do
|
||||
assert Bencode.decode("li1ei2ei3ee") == [1, 2, 3]
|
||||
end
|
||||
|
||||
test "decode dict" do
|
||||
result = Bencode.decode("d2:op4:evale")
|
||||
assert result == %{"op" => "eval"}
|
||||
end
|
||||
|
||||
test "roundtrip" do
|
||||
original = %{"op" => "eval", "code" => "(+ 1 2)", "id" => "msg-1"}
|
||||
assert Bencode.decode(Bencode.encode(original)) == original
|
||||
end
|
||||
|
||||
test "roundtrip nested" do
|
||||
original = %{"ops" => %{"eval" => %{}, "clone" => %{}}, "status" => ["done"]}
|
||||
assert Bencode.decode(Bencode.encode(original)) == original
|
||||
end
|
||||
end
|
||||
|
||||
# --- Session Manager Tests ---
|
||||
|
||||
describe "SessionManager" do
|
||||
setup do
|
||||
{:ok, manager} = SessionManager.start_link()
|
||||
%{manager: manager}
|
||||
end
|
||||
|
||||
test "create session", %{manager: manager} do
|
||||
id = SessionManager.create_session(manager)
|
||||
assert is_binary(id)
|
||||
assert String.length(id) > 0
|
||||
end
|
||||
|
||||
test "list sessions", %{manager: manager} do
|
||||
assert SessionManager.list_sessions(manager) == []
|
||||
id = SessionManager.create_session(manager)
|
||||
assert SessionManager.list_sessions(manager) == [id]
|
||||
end
|
||||
|
||||
test "eval in session", %{manager: manager} do
|
||||
id = SessionManager.create_session(manager)
|
||||
assert {:ok, "3"} = SessionManager.eval(manager, id, "(+ 1 2)")
|
||||
end
|
||||
|
||||
test "session state persists", %{manager: manager} do
|
||||
id = SessionManager.create_session(manager)
|
||||
|
||||
{:ok, _} =
|
||||
SessionManager.eval(manager, id, """
|
||||
(defmodule NReplSessionTest
|
||||
(defn hello [] :hi))
|
||||
""")
|
||||
|
||||
{:ok, result} = SessionManager.eval(manager, id, "(NReplSessionTest/hello)")
|
||||
assert result == ":hi"
|
||||
end
|
||||
|
||||
test "close session", %{manager: manager} do
|
||||
id = SessionManager.create_session(manager)
|
||||
assert :ok = SessionManager.close_session(manager, id)
|
||||
assert SessionManager.list_sessions(manager) == []
|
||||
end
|
||||
|
||||
test "independent sessions", %{manager: manager} do
|
||||
id1 = SessionManager.create_session(manager)
|
||||
id2 = SessionManager.create_session(manager)
|
||||
assert id1 != id2
|
||||
{:ok, "3"} = SessionManager.eval(manager, id1, "(+ 1 2)")
|
||||
{:ok, "7"} = SessionManager.eval(manager, id2, "(+ 3 4)")
|
||||
end
|
||||
end
|
||||
|
||||
# --- Handler Tests ---
|
||||
|
||||
describe "Handler" do
|
||||
setup do
|
||||
{:ok, manager} = SessionManager.start_link()
|
||||
%{manager: manager}
|
||||
end
|
||||
|
||||
test "clone creates session", %{manager: manager} do
|
||||
[response] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
||||
assert response["status"] == ["done"]
|
||||
assert is_binary(response["new-session"])
|
||||
end
|
||||
|
||||
test "describe returns ops", %{manager: manager} do
|
||||
[response] = Handler.handle(%{"op" => "describe", "id" => "1"}, manager)
|
||||
assert response["status"] == ["done"]
|
||||
assert is_map(response["ops"])
|
||||
assert Map.has_key?(response["ops"], "eval")
|
||||
assert Map.has_key?(response["ops"], "clone")
|
||||
end
|
||||
|
||||
test "eval returns value", %{manager: manager} do
|
||||
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
||||
session = clone_resp["new-session"]
|
||||
|
||||
responses =
|
||||
Handler.handle(
|
||||
%{
|
||||
"op" => "eval",
|
||||
"code" => "(+ 1 2)",
|
||||
"session" => session,
|
||||
"id" => "2"
|
||||
},
|
||||
manager
|
||||
)
|
||||
|
||||
# Should have value response and done response
|
||||
values = Enum.filter(responses, &Map.has_key?(&1, "value"))
|
||||
dones = Enum.filter(responses, fn r -> r["status"] == ["done"] end)
|
||||
|
||||
assert length(values) == 1
|
||||
assert hd(values)["value"] == "3"
|
||||
assert length(dones) == 1
|
||||
end
|
||||
|
||||
test "eval captures stdout", %{manager: manager} do
|
||||
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
||||
session = clone_resp["new-session"]
|
||||
|
||||
responses =
|
||||
Handler.handle(
|
||||
%{
|
||||
"op" => "eval",
|
||||
"code" => "(println \"hello from nrepl\")",
|
||||
"session" => session,
|
||||
"id" => "2"
|
||||
},
|
||||
manager
|
||||
)
|
||||
|
||||
outs = Enum.filter(responses, &Map.has_key?(&1, "out"))
|
||||
assert length(outs) >= 1
|
||||
assert hd(outs)["out"] =~ "hello from nrepl"
|
||||
end
|
||||
|
||||
test "eval error", %{manager: manager} do
|
||||
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
||||
session = clone_resp["new-session"]
|
||||
|
||||
responses =
|
||||
Handler.handle(
|
||||
%{
|
||||
"op" => "eval",
|
||||
"code" => "(defmodule HandlerErrTest (bad-syntax",
|
||||
"session" => session,
|
||||
"id" => "2"
|
||||
},
|
||||
manager
|
||||
)
|
||||
|
||||
errs = Enum.filter(responses, &Map.has_key?(&1, "err"))
|
||||
assert length(errs) >= 1
|
||||
end
|
||||
|
||||
test "ls-sessions", %{manager: manager} do
|
||||
[clone1] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
||||
[clone2] = Handler.handle(%{"op" => "clone", "id" => "2"}, manager)
|
||||
|
||||
[response] = Handler.handle(%{"op" => "ls-sessions", "id" => "3"}, manager)
|
||||
sessions = response["sessions"]
|
||||
|
||||
assert length(sessions) == 2
|
||||
assert clone1["new-session"] in sessions
|
||||
assert clone2["new-session"] in sessions
|
||||
end
|
||||
|
||||
test "close session", %{manager: manager} do
|
||||
[clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager)
|
||||
session = clone_resp["new-session"]
|
||||
|
||||
[close_resp] =
|
||||
Handler.handle(
|
||||
%{
|
||||
"op" => "close",
|
||||
"session" => session,
|
||||
"id" => "2"
|
||||
},
|
||||
manager
|
||||
)
|
||||
|
||||
assert close_resp["status"] == ["done"]
|
||||
|
||||
[ls_resp] = Handler.handle(%{"op" => "ls-sessions", "id" => "3"}, manager)
|
||||
assert ls_resp["sessions"] == []
|
||||
end
|
||||
|
||||
test "unknown op", %{manager: manager} do
|
||||
[response] = Handler.handle(%{"op" => "bogus", "id" => "1"}, manager)
|
||||
assert "unknown-op" in response["status"]
|
||||
end
|
||||
end
|
||||
|
||||
# --- TCP Integration Test ---
|
||||
|
||||
describe "TCP server" do
|
||||
test "server starts and accepts connections" do
|
||||
{:ok, server} = Server.start_link(port: 0)
|
||||
port = Server.port(server)
|
||||
assert port > 0
|
||||
|
||||
# Connect as a client
|
||||
{:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false])
|
||||
|
||||
# Send clone request
|
||||
clone_msg = Bencode.encode(%{"op" => "clone", "id" => "1"})
|
||||
:ok = :gen_tcp.send(socket, clone_msg)
|
||||
|
||||
# Read response
|
||||
{:ok, data} = :gen_tcp.recv(socket, 0, 5000)
|
||||
response = Bencode.decode(data)
|
||||
|
||||
assert response["status"] == ["done"]
|
||||
assert is_binary(response["new-session"])
|
||||
|
||||
:gen_tcp.close(socket)
|
||||
GenServer.stop(server)
|
||||
end
|
||||
|
||||
test "full eval over TCP" do
|
||||
{:ok, server} = Server.start_link(port: 0)
|
||||
port = Server.port(server)
|
||||
|
||||
{:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false])
|
||||
|
||||
# Clone
|
||||
:ok = :gen_tcp.send(socket, Bencode.encode(%{"op" => "clone", "id" => "1"}))
|
||||
{:ok, clone_data} = :gen_tcp.recv(socket, 0, 5000)
|
||||
clone_resp = Bencode.decode(clone_data)
|
||||
session = clone_resp["new-session"]
|
||||
|
||||
# Eval
|
||||
eval_msg =
|
||||
Bencode.encode(%{
|
||||
"op" => "eval",
|
||||
"code" => "(+ 21 21)",
|
||||
"session" => session,
|
||||
"id" => "2"
|
||||
})
|
||||
|
||||
:ok = :gen_tcp.send(socket, eval_msg)
|
||||
|
||||
# Read all responses (value + done)
|
||||
responses = read_all_responses(socket)
|
||||
|
||||
values = Enum.filter(responses, &Map.has_key?(&1, "value"))
|
||||
assert length(values) >= 1
|
||||
assert hd(values)["value"] == "42"
|
||||
|
||||
:gen_tcp.close(socket)
|
||||
GenServer.stop(server)
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to read multiple bencode responses
|
||||
defp read_all_responses(socket, acc \\ [], buffer \\ "") do
|
||||
case :gen_tcp.recv(socket, 0, 2000) do
|
||||
{:ok, data} ->
|
||||
new_buffer = buffer <> data
|
||||
{msgs, rest} = decode_available(new_buffer)
|
||||
new_acc = acc ++ msgs
|
||||
|
||||
if Enum.any?(new_acc, fn r -> r["status"] == ["done"] end) do
|
||||
new_acc
|
||||
else
|
||||
read_all_responses(socket, new_acc, rest)
|
||||
end
|
||||
|
||||
{:error, :timeout} ->
|
||||
{msgs, _rest} = decode_available(buffer)
|
||||
acc ++ msgs
|
||||
|
||||
{:error, _} ->
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_available(data, acc \\ []) do
|
||||
try do
|
||||
{msg, rest} = Bencode.decode_one(data)
|
||||
decode_available(rest, acc ++ [msg])
|
||||
rescue
|
||||
_ -> {acc, data}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,342 @@
|
||||
defmodule CljElixir.Phase8Test do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
defp eval!(source) do
|
||||
case CljElixir.Compiler.eval_string(source) do
|
||||
{:ok, result, _} -> result
|
||||
{:error, errors} -> raise "Compilation failed: #{inspect(errors)}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CljElixir.Printer.pr_str" do
|
||||
test "nil" do
|
||||
assert CljElixir.Printer.pr_str(nil) == "nil"
|
||||
end
|
||||
|
||||
test "booleans" do
|
||||
assert CljElixir.Printer.pr_str(true) == "true"
|
||||
assert CljElixir.Printer.pr_str(false) == "false"
|
||||
end
|
||||
|
||||
test "integers" do
|
||||
assert CljElixir.Printer.pr_str(42) == "42"
|
||||
assert CljElixir.Printer.pr_str(-7) == "-7"
|
||||
end
|
||||
|
||||
test "floats" do
|
||||
assert CljElixir.Printer.pr_str(3.14) == "3.14"
|
||||
end
|
||||
|
||||
test "strings are quoted" do
|
||||
assert CljElixir.Printer.pr_str("hello") == "\"hello\""
|
||||
end
|
||||
|
||||
test "strings with escapes" do
|
||||
assert CljElixir.Printer.pr_str("hello\nworld") == "\"hello\\nworld\""
|
||||
assert CljElixir.Printer.pr_str("say \"hi\"") == "\"say \\\"hi\\\"\""
|
||||
end
|
||||
|
||||
test "atoms/keywords" do
|
||||
assert CljElixir.Printer.pr_str(:hello) == ":hello"
|
||||
assert CljElixir.Printer.pr_str(:ok) == ":ok"
|
||||
end
|
||||
|
||||
test "lists" do
|
||||
assert CljElixir.Printer.pr_str([1, 2, 3]) == "(1 2 3)"
|
||||
assert CljElixir.Printer.pr_str([]) == "()"
|
||||
end
|
||||
|
||||
test "maps" do
|
||||
result = CljElixir.Printer.pr_str(%{name: "Ada"})
|
||||
assert result =~ ":name"
|
||||
assert result =~ "\"Ada\""
|
||||
assert String.starts_with?(result, "{")
|
||||
assert String.ends_with?(result, "}")
|
||||
end
|
||||
|
||||
test "tuples" do
|
||||
assert CljElixir.Printer.pr_str({:ok, 42}) == "#el[:ok 42]"
|
||||
end
|
||||
|
||||
test "nested structures" do
|
||||
result = CljElixir.Printer.pr_str(%{data: [1, 2, {:ok, "hi"}]})
|
||||
assert result =~ ":data"
|
||||
assert result =~ "(1 2 #el[:ok \"hi\"])"
|
||||
end
|
||||
|
||||
test "MapSet" do
|
||||
result = CljElixir.Printer.pr_str(MapSet.new([:a, :b]))
|
||||
assert String.starts_with?(result, "\#{")
|
||||
assert String.ends_with?(result, "}")
|
||||
end
|
||||
|
||||
test "module names" do
|
||||
assert CljElixir.Printer.pr_str(Enum) == "Enum"
|
||||
assert CljElixir.Printer.pr_str(IO) == "IO"
|
||||
end
|
||||
|
||||
test "pids" do
|
||||
result = CljElixir.Printer.pr_str(self())
|
||||
assert String.starts_with?(result, "#PID<")
|
||||
end
|
||||
end
|
||||
|
||||
describe "CljElixir.Printer.print_str" do
|
||||
test "strings not quoted" do
|
||||
assert CljElixir.Printer.print_str("hello") == "hello"
|
||||
end
|
||||
|
||||
test "non-strings same as pr_str" do
|
||||
assert CljElixir.Printer.print_str(42) == "42"
|
||||
assert CljElixir.Printer.print_str(:ok) == ":ok"
|
||||
end
|
||||
end
|
||||
|
||||
describe "pr-str builtin" do
|
||||
test "pr-str on integer" do
|
||||
result = eval!("(pr-str 42)")
|
||||
assert result == "42"
|
||||
end
|
||||
|
||||
test "pr-str on string" do
|
||||
result = eval!("(pr-str \"hello\")")
|
||||
assert result == "\"hello\""
|
||||
end
|
||||
|
||||
test "pr-str on keyword" do
|
||||
result = eval!("(pr-str :foo)")
|
||||
assert result == ":foo"
|
||||
end
|
||||
|
||||
test "pr-str on list" do
|
||||
result = eval!("(pr-str (list 1 2 3))")
|
||||
assert result == "(1 2 3)"
|
||||
end
|
||||
|
||||
test "pr-str on map" do
|
||||
result = eval!("(pr-str {:a 1})")
|
||||
assert result =~ ":a"
|
||||
assert result =~ "1"
|
||||
end
|
||||
|
||||
test "pr-str on tuple" do
|
||||
result = eval!("(pr-str #el[:ok 42])")
|
||||
assert result == "#el[:ok 42]"
|
||||
end
|
||||
|
||||
test "pr-str on nil" do
|
||||
result = eval!("(pr-str nil)")
|
||||
assert result == "nil"
|
||||
end
|
||||
|
||||
test "pr-str on boolean" do
|
||||
result = eval!("(pr-str true)")
|
||||
assert result == "true"
|
||||
end
|
||||
|
||||
test "pr-str multiple args joined with space" do
|
||||
result = eval!("(pr-str 1 2 3)")
|
||||
assert result == "1 2 3"
|
||||
end
|
||||
end
|
||||
|
||||
describe "print-str builtin" do
|
||||
test "print-str on string (no quotes)" do
|
||||
result = eval!("(print-str \"hello\")")
|
||||
assert result == "hello"
|
||||
end
|
||||
|
||||
test "print-str on integer" do
|
||||
result = eval!("(print-str 42)")
|
||||
assert result == "42"
|
||||
end
|
||||
|
||||
test "print-str multiple args joined with space" do
|
||||
result = eval!("(print-str \"hello\" \"world\")")
|
||||
assert result == "hello world"
|
||||
end
|
||||
end
|
||||
|
||||
describe "prn builtin" do
|
||||
test "prn outputs to stdout with newline" do
|
||||
output =
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
eval!("(prn 42)")
|
||||
end)
|
||||
|
||||
assert String.trim(output) == "42"
|
||||
end
|
||||
|
||||
test "prn with string (quoted)" do
|
||||
output =
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
eval!("(prn \"hello\")")
|
||||
end)
|
||||
|
||||
assert String.trim(output) == "\"hello\""
|
||||
end
|
||||
|
||||
test "prn multiple args" do
|
||||
output =
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
eval!("(prn 1 2 3)")
|
||||
end)
|
||||
|
||||
assert String.trim(output) == "1 2 3"
|
||||
end
|
||||
end
|
||||
|
||||
describe "pr builtin" do
|
||||
test "pr outputs to stdout without newline" do
|
||||
output =
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
eval!("(pr 42)")
|
||||
end)
|
||||
|
||||
assert output == "42"
|
||||
end
|
||||
|
||||
test "pr with string (quoted)" do
|
||||
output =
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
eval!("(pr \"hello\")")
|
||||
end)
|
||||
|
||||
assert output == "\"hello\""
|
||||
end
|
||||
|
||||
test "pr multiple args separated by spaces" do
|
||||
output =
|
||||
ExUnit.CaptureIO.capture_io(fn ->
|
||||
eval!("(pr 1 2 3)")
|
||||
end)
|
||||
|
||||
assert output == "1 2 3"
|
||||
end
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source-mapped line/col metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
describe "source-mapped metadata" do
|
||||
test "symbol AST carries line/col from reader" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(+ x 1)")
|
||||
# x should have line: 1 metadata in the Elixir AST
|
||||
# Walk the AST to find the :x variable
|
||||
{_, found} = Macro.prewalk(ast, false, fn
|
||||
{:x, meta, nil} = node, _acc when is_list(meta) ->
|
||||
{node, meta[:line] == 1}
|
||||
node, acc ->
|
||||
{node, acc}
|
||||
end)
|
||||
assert found, "variable :x should have line: 1 metadata"
|
||||
end
|
||||
|
||||
test "defmodule AST carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(defmodule Foo (defn bar [] 1))")
|
||||
# The defmodule node should have line: 1
|
||||
{:defmodule, meta, _} = ast
|
||||
assert meta[:line] == 1
|
||||
end
|
||||
|
||||
test "defn AST carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("""
|
||||
(defmodule MetaTest1
|
||||
(defn foo [x] x))
|
||||
""")
|
||||
# Find the :def node inside the module body
|
||||
{_, found_line} = Macro.prewalk(ast, nil, fn
|
||||
{:def, meta, _} = node, nil when is_list(meta) ->
|
||||
{node, Keyword.get(meta, :line)}
|
||||
node, acc ->
|
||||
{node, acc}
|
||||
end)
|
||||
assert found_line == 2, "defn should have line: 2 metadata"
|
||||
end
|
||||
|
||||
test "if AST carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(if true 1 2)")
|
||||
{:if, meta, _} = ast
|
||||
assert meta[:line] == 1
|
||||
end
|
||||
|
||||
test "fn AST carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(fn [x] x)")
|
||||
{:fn, meta, _} = ast
|
||||
assert meta[:line] == 1
|
||||
end
|
||||
|
||||
test "case AST carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(case 1 1 :one 2 :two)")
|
||||
{:case, meta, _} = ast
|
||||
assert meta[:line] == 1
|
||||
end
|
||||
|
||||
test "cond AST carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(cond true :yes)")
|
||||
{:cond, meta, _} = ast
|
||||
assert meta[:line] == 1
|
||||
end
|
||||
|
||||
test "module call AST carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(Enum/map [1 2] inc)")
|
||||
# The outer call should be {{:., meta, [Enum, :map]}, meta, args}
|
||||
{{:., dot_meta, _}, call_meta, _} = ast
|
||||
assert dot_meta[:line] == 1
|
||||
assert call_meta[:line] == 1
|
||||
end
|
||||
|
||||
test "unqualified call AST carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("""
|
||||
(defmodule MetaTest2
|
||||
(defn foo [] (bar 1)))
|
||||
""")
|
||||
# Find the :bar call inside the module
|
||||
{_, found_line} = Macro.prewalk(ast, nil, fn
|
||||
{:bar, meta, [1]} = node, nil when is_list(meta) ->
|
||||
{node, meta[:line]}
|
||||
node, acc ->
|
||||
{node, acc}
|
||||
end)
|
||||
assert found_line == 2, "unqualified call should have line: 2 metadata"
|
||||
end
|
||||
|
||||
test "runtime error has source location" do
|
||||
# This tests that evaluated code preserves source info
|
||||
result = CljElixir.Compiler.eval_string("""
|
||||
(defmodule LineTest1
|
||||
(defn hello [name]
|
||||
(str "hello " name)))
|
||||
(LineTest1/hello "world")
|
||||
""", file: "line_test.clje")
|
||||
|
||||
assert {:ok, "hello world", _} = result
|
||||
end
|
||||
|
||||
test "let block carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(let [x 1] x)")
|
||||
# let produces either a block or a single = expression
|
||||
case ast do
|
||||
{:__block__, meta, _} -> assert meta[:line] == 1
|
||||
{:=, meta, _} -> assert meta[:line] == 1
|
||||
end
|
||||
end
|
||||
|
||||
test "for comprehension carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("(for [x [1 2 3]] x)")
|
||||
{:for, meta, _} = ast
|
||||
assert meta[:line] == 1
|
||||
end
|
||||
|
||||
test "try carries line metadata" do
|
||||
{:ok, ast} = CljElixir.Compiler.compile_string("""
|
||||
(try
|
||||
(throw "oops")
|
||||
(catch e (str "caught: " e)))
|
||||
""")
|
||||
{:try, meta, _} = ast
|
||||
assert meta[:line] == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
defmodule CljElixir.REPLTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias CljElixir.REPL
|
||||
|
||||
describe "REPL.new" do
|
||||
test "creates initial state" do
|
||||
state = REPL.new()
|
||||
assert state.bindings == []
|
||||
assert state.history == []
|
||||
assert state.counter == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "REPL.eval" do
|
||||
test "evaluates simple expression" do
|
||||
state = REPL.new()
|
||||
assert {:ok, "3", _} = REPL.eval("(+ 1 2)", state)
|
||||
end
|
||||
|
||||
test "pr-str formats output" do
|
||||
state = REPL.new()
|
||||
{:ok, result, _} = REPL.eval("\"hello\"", state)
|
||||
assert result == "\"hello\""
|
||||
end
|
||||
|
||||
test "nil result" do
|
||||
state = REPL.new()
|
||||
{:ok, result, _} = REPL.eval("nil", state)
|
||||
assert result == "nil"
|
||||
end
|
||||
|
||||
test "map result" do
|
||||
state = REPL.new()
|
||||
{:ok, result, _} = REPL.eval("{:a 1 :b 2}", state)
|
||||
assert result =~ ":a"
|
||||
assert result =~ "1"
|
||||
end
|
||||
|
||||
test "increments counter" do
|
||||
state = REPL.new()
|
||||
{:ok, _, state2} = REPL.eval("1", state)
|
||||
assert state2.counter == 2
|
||||
{:ok, _, state3} = REPL.eval("2", state2)
|
||||
assert state3.counter == 3
|
||||
end
|
||||
|
||||
test "stores history" do
|
||||
state = REPL.new()
|
||||
{:ok, _, state2} = REPL.eval("(+ 1 2)", state)
|
||||
assert state2.history == ["(+ 1 2)"]
|
||||
{:ok, _, state3} = REPL.eval("(+ 3 4)", state2)
|
||||
assert state3.history == ["(+ 3 4)", "(+ 1 2)"]
|
||||
end
|
||||
|
||||
test "error returns error tuple" do
|
||||
state = REPL.new()
|
||||
{:error, msg, _} = REPL.eval("(defmodule REPLErrTest (invalid-syntax", state)
|
||||
assert is_binary(msg)
|
||||
assert msg =~ "Error" or msg =~ "error"
|
||||
end
|
||||
|
||||
test "defmodule persists across evals" do
|
||||
state = REPL.new()
|
||||
|
||||
{:ok, _, state2} =
|
||||
REPL.eval("""
|
||||
(defmodule REPLTestMod
|
||||
(defn hello [] :hi))
|
||||
""", state)
|
||||
|
||||
{:ok, result, _} = REPL.eval("(REPLTestMod/hello)", state2)
|
||||
assert result == ":hi"
|
||||
end
|
||||
|
||||
test "tuple result" do
|
||||
state = REPL.new()
|
||||
{:ok, result, _} = REPL.eval("#el[:ok 42]", state)
|
||||
assert result == "#el[:ok 42]"
|
||||
end
|
||||
|
||||
test "list result" do
|
||||
state = REPL.new()
|
||||
{:ok, result, _} = REPL.eval("(list 1 2 3)", state)
|
||||
assert result == "(1 2 3)"
|
||||
end
|
||||
end
|
||||
|
||||
describe "REPL.balanced?" do
|
||||
test "balanced parens" do
|
||||
assert REPL.balanced?("(+ 1 2)")
|
||||
assert REPL.balanced?("(defn foo [x] (+ x 1))")
|
||||
assert REPL.balanced?("42")
|
||||
assert REPL.balanced?("")
|
||||
end
|
||||
|
||||
test "unbalanced parens" do
|
||||
refute REPL.balanced?("(+ 1 2")
|
||||
refute REPL.balanced?("(defn foo [x]")
|
||||
refute REPL.balanced?("(let [x 1")
|
||||
end
|
||||
|
||||
test "balanced with nested" do
|
||||
assert REPL.balanced?("(let [x (+ 1 2)] (* x x))")
|
||||
end
|
||||
|
||||
test "string contents not counted" do
|
||||
assert REPL.balanced?("(str \"(hello)\")")
|
||||
assert REPL.balanced?("(str \"[not real]\")")
|
||||
end
|
||||
|
||||
test "comment contents not counted" do
|
||||
assert REPL.balanced?("(+ 1 2) ; this has unbalanced (")
|
||||
end
|
||||
|
||||
test "mixed delimiters" do
|
||||
assert REPL.balanced?("(let [{:keys [a b]} {:a 1 :b 2}] (+ a b))")
|
||||
refute REPL.balanced?("(let [{:keys [a b]} {:a 1 :b 2}] (+ a b)")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1079,12 +1079,12 @@ defmodule CljElixir.TransformerTest do
|
||||
describe "dynamic vars" do
|
||||
test "*self* produces self() call" do
|
||||
ast = transform("*self*")
|
||||
assert ast == {:self, [], []}
|
||||
assert match?({:self, _, []}, ast)
|
||||
end
|
||||
|
||||
test "*node* produces node() call" do
|
||||
ast = transform("*node*")
|
||||
assert ast == {:node, [], []}
|
||||
assert match?({:node, _, []}, ast)
|
||||
end
|
||||
|
||||
test "*self* evaluates to current process" do
|
||||
@@ -1131,12 +1131,12 @@ defmodule CljElixir.TransformerTest do
|
||||
describe "symbols" do
|
||||
test "plain symbol becomes variable" do
|
||||
ast = transform("x")
|
||||
assert ast == {:x, [], nil}
|
||||
assert match?({:x, _, nil}, ast)
|
||||
end
|
||||
|
||||
test "symbol with hyphens becomes munged variable" do
|
||||
ast = transform("my-var")
|
||||
assert ast == {:my_var, [], nil}
|
||||
assert match?({:my_var, _, nil}, ast)
|
||||
end
|
||||
|
||||
test "true symbol becomes true literal" do
|
||||
|
||||
Reference in New Issue
Block a user