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 <> = :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