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,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
|
||||
Reference in New Issue
Block a user