Files
2026-03-09 23:09:46 -04:00

151 lines
4.1 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"}, "user"}, state}
pid ->
{output, result, ns} =
Agent.get_and_update(
pid,
fn repl_state ->
{output, eval_result, new_state} = eval_capturing_output(code, repl_state)
ns = CljElixir.REPL.current_ns(new_state)
{{output, eval_result, ns}, new_state}
end,
:infinity
)
{:reply, {output, result, ns}, 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