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,96 @@
|
||||
defmodule CljElixir.NRepl.Bencode do
|
||||
@moduledoc "Bencode encoder/decoder for nREPL wire protocol."
|
||||
|
||||
# --- Encoder ---
|
||||
|
||||
def encode(data) when is_binary(data) do
|
||||
"#{byte_size(data)}:#{data}"
|
||||
end
|
||||
|
||||
def encode(data) when is_integer(data) do
|
||||
"i#{data}e"
|
||||
end
|
||||
|
||||
def encode(data) when is_list(data) do
|
||||
"l" <> Enum.map_join(data, "", &encode/1) <> "e"
|
||||
end
|
||||
|
||||
def encode(data) when is_map(data) do
|
||||
sorted = data |> Enum.sort_by(fn {k, _} -> to_string(k) end)
|
||||
|
||||
"d" <>
|
||||
Enum.map_join(sorted, "", fn {k, v} ->
|
||||
encode(to_string(k)) <> encode(v)
|
||||
end) <> "e"
|
||||
end
|
||||
|
||||
def encode(data) when is_atom(data) do
|
||||
encode(Atom.to_string(data))
|
||||
end
|
||||
|
||||
# --- Decoder ---
|
||||
|
||||
def decode(data) when is_binary(data) do
|
||||
{result, _rest} = decode_one(data)
|
||||
result
|
||||
end
|
||||
|
||||
def decode_all(data) do
|
||||
decode_all(data, [])
|
||||
end
|
||||
|
||||
defp decode_all("", acc), do: Enum.reverse(acc)
|
||||
|
||||
defp decode_all(data, acc) do
|
||||
{result, rest} = decode_one(data)
|
||||
decode_all(rest, [result | acc])
|
||||
end
|
||||
|
||||
# Public so the TCP server can call it for streaming decode
|
||||
def decode_one("i" <> rest) do
|
||||
{num_str, "e" <> rest2} = split_at_char(rest, ?e)
|
||||
{String.to_integer(num_str), rest2}
|
||||
end
|
||||
|
||||
def decode_one("l" <> rest) do
|
||||
decode_list(rest, [])
|
||||
end
|
||||
|
||||
def decode_one("d" <> rest) do
|
||||
decode_dict(rest, %{})
|
||||
end
|
||||
|
||||
def decode_one(data) do
|
||||
# String: <length>:<data>
|
||||
{len_str, ":" <> rest} = split_at_char(data, ?:)
|
||||
len = String.to_integer(len_str)
|
||||
<<str::binary-size(len), rest2::binary>> = rest
|
||||
{str, rest2}
|
||||
end
|
||||
|
||||
defp decode_list("e" <> rest, acc), do: {Enum.reverse(acc), rest}
|
||||
|
||||
defp decode_list(data, acc) do
|
||||
{item, rest} = decode_one(data)
|
||||
decode_list(rest, [item | acc])
|
||||
end
|
||||
|
||||
defp decode_dict("e" <> rest, acc), do: {acc, rest}
|
||||
|
||||
defp decode_dict(data, acc) do
|
||||
{key, rest} = decode_one(data)
|
||||
{value, rest2} = decode_one(rest)
|
||||
decode_dict(rest2, Map.put(acc, key, value))
|
||||
end
|
||||
|
||||
defp split_at_char(data, char) do
|
||||
case :binary.match(data, <<char>>) do
|
||||
{pos, 1} ->
|
||||
<<before::binary-size(pos), _::8, rest::binary>> = data
|
||||
{before, <<char, rest::binary>>}
|
||||
|
||||
:nomatch ->
|
||||
{data, ""}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
defmodule CljElixir.NRepl.Handler do
|
||||
@moduledoc "Handles nREPL protocol messages."
|
||||
|
||||
alias CljElixir.NRepl.SessionManager
|
||||
|
||||
def handle(msg, session_manager) do
|
||||
op = Map.get(msg, "op")
|
||||
id = Map.get(msg, "id", "unknown")
|
||||
session = Map.get(msg, "session")
|
||||
|
||||
case op do
|
||||
"clone" -> handle_clone(id, session, session_manager)
|
||||
"close" -> handle_close(id, session, session_manager)
|
||||
"eval" -> handle_eval(msg, id, session, session_manager)
|
||||
"describe" -> handle_describe(id, session)
|
||||
"ls-sessions" -> handle_ls_sessions(id, session_manager)
|
||||
"load-file" -> handle_load_file(msg, id, session, session_manager)
|
||||
"interrupt" -> handle_interrupt(id, session)
|
||||
"completions" -> handle_completions(msg, id, session)
|
||||
_ -> [%{"id" => id, "session" => session || "", "status" => ["done", "error", "unknown-op"]}]
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_clone(id, _session, manager) do
|
||||
new_id = SessionManager.create_session(manager)
|
||||
[%{"id" => id, "new-session" => new_id, "status" => ["done"]}]
|
||||
end
|
||||
|
||||
defp handle_close(id, session, manager) do
|
||||
SessionManager.close_session(manager, session)
|
||||
[%{"id" => id, "session" => session, "status" => ["done"]}]
|
||||
end
|
||||
|
||||
defp handle_eval(msg, id, session, manager) do
|
||||
code = Map.get(msg, "code", "")
|
||||
|
||||
# Capture stdout inside the Agent process where eval actually runs
|
||||
{output, result} = SessionManager.eval_with_capture(manager, session, code)
|
||||
|
||||
responses = []
|
||||
|
||||
# Send stdout if any
|
||||
responses =
|
||||
if output != "" do
|
||||
responses ++ [%{"id" => id, "session" => session, "out" => output}]
|
||||
else
|
||||
responses
|
||||
end
|
||||
|
||||
# Send value or error
|
||||
responses =
|
||||
case result do
|
||||
{:ok, value} ->
|
||||
responses ++ [%{"id" => id, "session" => session, "value" => value, "ns" => "user"}]
|
||||
|
||||
{:error, error} ->
|
||||
responses ++
|
||||
[%{"id" => id, "session" => session, "err" => error, "status" => ["eval-error"]}]
|
||||
end
|
||||
|
||||
# Send done
|
||||
responses ++ [%{"id" => id, "session" => session, "status" => ["done"]}]
|
||||
end
|
||||
|
||||
defp handle_describe(id, session) do
|
||||
[
|
||||
%{
|
||||
"id" => id,
|
||||
"session" => session || "",
|
||||
"status" => ["done"],
|
||||
"ops" => %{
|
||||
"clone" => %{},
|
||||
"close" => %{},
|
||||
"eval" => %{},
|
||||
"describe" => %{},
|
||||
"ls-sessions" => %{},
|
||||
"load-file" => %{},
|
||||
"interrupt" => %{},
|
||||
"completions" => %{}
|
||||
},
|
||||
"versions" => %{
|
||||
"clj-elixir" => %{"major" => 0, "minor" => 1, "incremental" => 0},
|
||||
"nrepl" => %{"major" => 1, "minor" => 0, "incremental" => 0}
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp handle_ls_sessions(id, manager) do
|
||||
sessions = SessionManager.list_sessions(manager)
|
||||
[%{"id" => id, "sessions" => sessions, "status" => ["done"]}]
|
||||
end
|
||||
|
||||
defp handle_load_file(msg, id, session, manager) do
|
||||
code = Map.get(msg, "file", "")
|
||||
handle_eval(Map.put(msg, "code", code), id, session, manager)
|
||||
end
|
||||
|
||||
defp handle_interrupt(id, session) do
|
||||
# Not truly implemented -- just acknowledge
|
||||
[%{"id" => id, "session" => session, "status" => ["done"]}]
|
||||
end
|
||||
|
||||
defp handle_completions(msg, id, session) do
|
||||
_prefix = Map.get(msg, "prefix", "")
|
||||
# Basic completions -- return empty for now, can be extended later
|
||||
[%{"id" => id, "session" => session || "", "completions" => [], "status" => ["done"]}]
|
||||
end
|
||||
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
defmodule CljElixir.NRepl.Server do
|
||||
@moduledoc """
|
||||
nREPL TCP server for CljElixir.
|
||||
|
||||
Listens on a configurable port and handles nREPL protocol messages
|
||||
over TCP using Bencode encoding.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias CljElixir.NRepl.{Bencode, Handler, SessionManager}
|
||||
|
||||
defstruct [:listen_socket, :port, :session_manager]
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
port = Keyword.get(opts, :port, 0)
|
||||
GenServer.start_link(__MODULE__, port, opts)
|
||||
end
|
||||
|
||||
def port(server) do
|
||||
GenServer.call(server, :port)
|
||||
end
|
||||
|
||||
# --- Callbacks ---
|
||||
|
||||
@impl true
|
||||
def init(port) do
|
||||
{:ok, manager} = SessionManager.start_link()
|
||||
|
||||
# Open TCP listener
|
||||
opts = [:binary, packet: :raw, active: false, reuseaddr: true]
|
||||
|
||||
case :gen_tcp.listen(port, opts) do
|
||||
{:ok, listen_socket} ->
|
||||
# Get actual port (important when port=0)
|
||||
{:ok, actual_port} = :inet.port(listen_socket)
|
||||
|
||||
state = %__MODULE__{
|
||||
listen_socket: listen_socket,
|
||||
port: actual_port,
|
||||
session_manager: manager
|
||||
}
|
||||
|
||||
# Start accepting connections in a separate process
|
||||
spawn_link(fn -> accept_loop(listen_socket, manager) end)
|
||||
|
||||
Logger.info("nREPL server started on port #{actual_port}")
|
||||
{:ok, state}
|
||||
|
||||
{:error, reason} ->
|
||||
{:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:port, _from, state) do
|
||||
{:reply, state.port, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
if state.listen_socket do
|
||||
:gen_tcp.close(state.listen_socket)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# --- Connection handling ---
|
||||
|
||||
defp accept_loop(listen_socket, manager) do
|
||||
case :gen_tcp.accept(listen_socket) do
|
||||
{:ok, client_socket} ->
|
||||
spawn(fn -> handle_connection(client_socket, manager) end)
|
||||
accept_loop(listen_socket, manager)
|
||||
|
||||
{:error, :closed} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Accept error: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_connection(socket, manager) do
|
||||
case read_message(socket, "") do
|
||||
{:ok, msg, rest} ->
|
||||
responses = Handler.handle(msg, manager)
|
||||
|
||||
Enum.each(responses, fn response ->
|
||||
encoded = Bencode.encode(response)
|
||||
:gen_tcp.send(socket, encoded)
|
||||
end)
|
||||
|
||||
handle_connection_with_buffer(socket, manager, rest)
|
||||
|
||||
{:error, _reason} ->
|
||||
:gen_tcp.close(socket)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_connection_with_buffer(socket, manager, buffer) do
|
||||
case try_decode(buffer) do
|
||||
{:ok, msg, rest} ->
|
||||
responses = Handler.handle(msg, manager)
|
||||
|
||||
Enum.each(responses, fn response ->
|
||||
encoded = Bencode.encode(response)
|
||||
:gen_tcp.send(socket, encoded)
|
||||
end)
|
||||
|
||||
handle_connection_with_buffer(socket, manager, rest)
|
||||
|
||||
:incomplete ->
|
||||
case :gen_tcp.recv(socket, 0) do
|
||||
{:ok, data} ->
|
||||
handle_connection_with_buffer(socket, manager, buffer <> data)
|
||||
|
||||
{:error, _} ->
|
||||
:gen_tcp.close(socket)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp read_message(socket, buffer) do
|
||||
case try_decode(buffer) do
|
||||
{:ok, msg, rest} ->
|
||||
{:ok, msg, rest}
|
||||
|
||||
:incomplete ->
|
||||
case :gen_tcp.recv(socket, 0) do
|
||||
{:ok, data} -> read_message(socket, buffer <> data)
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp try_decode(data) when byte_size(data) == 0, do: :incomplete
|
||||
|
||||
defp try_decode(data) do
|
||||
try do
|
||||
{msg, rest} = Bencode.decode_one(data)
|
||||
{:ok, msg, rest}
|
||||
rescue
|
||||
_ -> :incomplete
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,149 @@
|
||||
defmodule CljElixir.NRepl.SessionManager do
|
||||
@moduledoc "Manages nREPL sessions with independent REPL state."
|
||||
|
||||
use GenServer
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, %{}, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
{:ok, %{sessions: %{}}}
|
||||
end
|
||||
|
||||
def create_session(manager) do
|
||||
GenServer.call(manager, :create_session)
|
||||
end
|
||||
|
||||
def close_session(manager, session_id) do
|
||||
GenServer.call(manager, {:close_session, session_id})
|
||||
end
|
||||
|
||||
def list_sessions(manager) do
|
||||
GenServer.call(manager, :list_sessions)
|
||||
end
|
||||
|
||||
def eval(manager, session_id, code) do
|
||||
GenServer.call(manager, {:eval, session_id, code}, :infinity)
|
||||
end
|
||||
|
||||
@doc "Eval with stdout capture. Returns {output, result} where result is {:ok, value} | {:error, error}."
|
||||
def eval_with_capture(manager, session_id, code) do
|
||||
GenServer.call(manager, {:eval_with_capture, session_id, code}, :infinity)
|
||||
end
|
||||
|
||||
# --- Callbacks ---
|
||||
|
||||
@impl true
|
||||
def handle_call(:create_session, _from, state) do
|
||||
id = generate_uuid()
|
||||
{:ok, pid} = Agent.start_link(fn -> CljElixir.REPL.new() end)
|
||||
new_sessions = Map.put(state.sessions, id, pid)
|
||||
{:reply, id, %{state | sessions: new_sessions}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:close_session, id}, _from, state) do
|
||||
case Map.pop(state.sessions, id) do
|
||||
{nil, _} ->
|
||||
{:reply, :not_found, state}
|
||||
|
||||
{pid, new_sessions} ->
|
||||
Agent.stop(pid)
|
||||
{:reply, :ok, %{state | sessions: new_sessions}}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:list_sessions, _from, state) do
|
||||
{:reply, Map.keys(state.sessions), state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:eval, id, code}, _from, state) do
|
||||
case Map.get(state.sessions, id) do
|
||||
nil ->
|
||||
{:reply, {:error, "unknown session"}, state}
|
||||
|
||||
pid ->
|
||||
result =
|
||||
Agent.get_and_update(
|
||||
pid,
|
||||
fn repl_state ->
|
||||
case CljElixir.REPL.eval(code, repl_state) do
|
||||
{:ok, value, new_state} -> {{:ok, value}, new_state}
|
||||
{:error, error, new_state} -> {{:error, error}, new_state}
|
||||
end
|
||||
end,
|
||||
:infinity
|
||||
)
|
||||
|
||||
{:reply, result, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:eval_with_capture, id, code}, _from, state) do
|
||||
case Map.get(state.sessions, id) do
|
||||
nil ->
|
||||
{:reply, {"", {:error, "unknown session"}}, state}
|
||||
|
||||
pid ->
|
||||
{output, result} =
|
||||
Agent.get_and_update(
|
||||
pid,
|
||||
fn repl_state ->
|
||||
{output, eval_result, new_state} = eval_capturing_output(code, repl_state)
|
||||
|
||||
{{output, eval_result}, new_state}
|
||||
end,
|
||||
:infinity
|
||||
)
|
||||
|
||||
{:reply, {output, result}, state}
|
||||
end
|
||||
end
|
||||
|
||||
# Evaluate code while capturing stdout within the current process
|
||||
defp eval_capturing_output(code, repl_state) do
|
||||
{:ok, string_io} = StringIO.open("")
|
||||
old_gl = Process.group_leader()
|
||||
Process.group_leader(self(), string_io)
|
||||
|
||||
try do
|
||||
case CljElixir.REPL.eval(code, repl_state) do
|
||||
{:ok, value, new_state} ->
|
||||
Process.group_leader(self(), old_gl)
|
||||
{_input, output} = StringIO.contents(string_io)
|
||||
StringIO.close(string_io)
|
||||
{output, {:ok, value}, new_state}
|
||||
|
||||
{:error, error, new_state} ->
|
||||
Process.group_leader(self(), old_gl)
|
||||
{_input, output} = StringIO.contents(string_io)
|
||||
StringIO.close(string_io)
|
||||
{output, {:error, error}, new_state}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Process.group_leader(self(), old_gl)
|
||||
StringIO.close(string_io)
|
||||
{"", {:error, Exception.message(e)}, repl_state}
|
||||
end
|
||||
end
|
||||
|
||||
# Simple UUID v4 generation using :crypto (OTP built-in, no deps)
|
||||
defp generate_uuid do
|
||||
<<a::32, b::16, c::16, d::16, e::48>> = :crypto.strong_rand_bytes(16)
|
||||
|
||||
:io_lib.format("~8.16.0b-~4.16.0b-4~3.16.0b-~4.16.0b-~12.16.0b", [
|
||||
a,
|
||||
b,
|
||||
Bitwise.band(c, 0x0FFF),
|
||||
Bitwise.bor(Bitwise.band(d, 0x3FFF), 0x8000),
|
||||
e
|
||||
])
|
||||
|> IO.iodata_to_binary()
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user