Files
CljElixir/lib/clj_elixir/nrepl/session.ex
Adam 7e82efd7ec 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>
2026-03-08 11:03:10 -04:00

150 lines
4.0 KiB
Elixir

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