diff --git a/lib/clj_elixir/nrepl/bencode.ex b/lib/clj_elixir/nrepl/bencode.ex new file mode 100644 index 0000000..5ea2c1e --- /dev/null +++ b/lib/clj_elixir/nrepl/bencode.ex @@ -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: : + {len_str, ":" <> rest} = split_at_char(data, ?:) + len = String.to_integer(len_str) + <> = 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, <>) do + {pos, 1} -> + <> = data + {before, <>} + + :nomatch -> + {data, ""} + end + end +end diff --git a/lib/clj_elixir/nrepl/handler.ex b/lib/clj_elixir/nrepl/handler.ex new file mode 100644 index 0000000..a488b14 --- /dev/null +++ b/lib/clj_elixir/nrepl/handler.ex @@ -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 diff --git a/lib/clj_elixir/nrepl/server.ex b/lib/clj_elixir/nrepl/server.ex new file mode 100644 index 0000000..5eef976 --- /dev/null +++ b/lib/clj_elixir/nrepl/server.ex @@ -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 diff --git a/lib/clj_elixir/nrepl/session.ex b/lib/clj_elixir/nrepl/session.ex new file mode 100644 index 0000000..92ba5ef --- /dev/null +++ b/lib/clj_elixir/nrepl/session.ex @@ -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 + <> = :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 diff --git a/lib/clj_elixir/printer.ex b/lib/clj_elixir/printer.ex new file mode 100644 index 0000000..bb416ca --- /dev/null +++ b/lib/clj_elixir/printer.ex @@ -0,0 +1,145 @@ +defmodule CljElixir.Printer do + @moduledoc """ + Machine-readable printing for CljElixir values. + + Produces EDN-compatible string representations: + - Strings: `"\"hello\""` + - Keywords/atoms: `":name"` + - Integers: `"42"` + - Maps: `"{:name \"Ada\", :age 30}"` + - Lists: `"(1 2 3)"` + - Vectors (PersistentVector): `"[1 2 3]"` + - Tuples: `"#el[:ok \"data\"]"` + - Sets (MapSet): `"\#{:a :b :c}"` + - nil: `"nil"` + - Booleans: `"true"` / `"false"` + - Records (structs): `"#RecordName{:field val, ...}"` + """ + + @doc "Convert value to machine-readable string (EDN-like)" + def pr_str(value) do + # Check if the value implements IPrintWithWriter protocol. + # If so, use it. Otherwise, use built-in printing. + if protocol_implemented?(value) do + try do + CljElixir.IPrintWithWriter.pr_writer(value, nil, nil) + rescue + _ -> do_pr_str(value) + end + else + do_pr_str(value) + end + end + + @doc "Convert value to human-readable string" + def print_str(value) do + do_print_str(value) + end + + # Check if IPrintWithWriter is compiled and implemented for this value + defp protocol_implemented?(value) do + case Code.ensure_loaded(CljElixir.IPrintWithWriter) do + {:module, _} -> + CljElixir.IPrintWithWriter.impl_for(value) != nil + + _ -> + false + end + end + + # --- Machine-readable (EDN) --- + + defp do_pr_str(nil), do: "nil" + defp do_pr_str(true), do: "true" + defp do_pr_str(false), do: "false" + + defp do_pr_str(n) when is_integer(n), do: Integer.to_string(n) + defp do_pr_str(n) when is_float(n), do: Float.to_string(n) + + defp do_pr_str(s) when is_binary(s) do + "\"" <> escape_string(s) <> "\"" + end + + defp do_pr_str(a) when is_atom(a) do + name = Atom.to_string(a) + + if String.starts_with?(name, "Elixir.") do + # Module name - strip Elixir. prefix + String.replace_prefix(name, "Elixir.", "") + else + ":" <> name + end + end + + defp do_pr_str(list) when is_list(list) do + "(" <> Enum.map_join(list, " ", &pr_str/1) <> ")" + end + + defp do_pr_str(%MapSet{} = set) do + "\#{" <> Enum.map_join(set, " ", &pr_str/1) <> "}" + end + + # Struct handling - use runtime __struct__ check for PersistentVector/SubVector + # since they are .clje modules not available at compile time. + defp do_pr_str(%{__struct__: struct_mod} = struct) do + struct_name = Atom.to_string(struct_mod) + + cond do + String.ends_with?(struct_name, "PersistentVector") -> + # PersistentVector - print as [...] + elements = apply(struct_mod, :to_list, [struct]) + "[" <> Enum.map_join(elements, " ", &pr_str/1) <> "]" + + String.ends_with?(struct_name, "SubVector") -> + # SubVector - print as [...] + elements = apply(struct_mod, :sv_to_list, [struct]) + "[" <> Enum.map_join(elements, " ", &pr_str/1) <> "]" + + true -> + # Generic struct/record + mod_name = struct_mod |> Module.split() |> List.last() + fields = struct |> Map.from_struct() |> Map.to_list() + + "#" <> + mod_name <> + "{" <> + Enum.map_join(fields, ", ", fn {k, v} -> pr_str(k) <> " " <> pr_str(v) end) <> + "}" + end + end + + # Plain map + defp do_pr_str(map) when is_map(map) do + "{" <> + Enum.map_join(map, ", ", fn {k, v} -> pr_str(k) <> " " <> pr_str(v) end) <> + "}" + end + + defp do_pr_str(tuple) when is_tuple(tuple) do + elements = Tuple.to_list(tuple) + "#el[" <> Enum.map_join(elements, " ", &pr_str/1) <> "]" + end + + defp do_pr_str(pid) when is_pid(pid), do: inspect(pid) + defp do_pr_str(ref) when is_reference(ref), do: inspect(ref) + defp do_pr_str(port) when is_port(port), do: inspect(port) + defp do_pr_str(fun) when is_function(fun), do: inspect(fun) + + defp do_pr_str(other), do: inspect(other) + + # --- Human-readable --- + + defp do_print_str(s) when is_binary(s), do: s + defp do_print_str(other), do: do_pr_str(other) + + # --- String escaping --- + + defp escape_string(s) do + s + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") + |> String.replace("\n", "\\n") + |> String.replace("\t", "\\t") + |> String.replace("\r", "\\r") + end +end diff --git a/lib/clj_elixir/repl.ex b/lib/clj_elixir/repl.ex new file mode 100644 index 0000000..7c4f669 --- /dev/null +++ b/lib/clj_elixir/repl.ex @@ -0,0 +1,110 @@ +defmodule CljElixir.REPL do + @moduledoc """ + CljElixir Read-Eval-Print Loop engine. + + Maintains state across evaluations: bindings persist, + modules defined in one evaluation are available in the next. + """ + + defstruct bindings: [], + history: [], + counter: 1, + env: nil + + @doc "Create a new REPL state" + def new do + %__MODULE__{} + end + + @doc """ + Evaluate a CljElixir source string in the given REPL state. + Returns {:ok, result_string, new_state} or {:error, error_string, new_state}. + """ + def eval(source, state) do + opts = [ + bindings: state.bindings, + file: "repl" + ] + + case CljElixir.Compiler.eval_string(source, opts) do + {:ok, result, new_bindings} -> + result_str = CljElixir.Printer.pr_str(result) + + new_state = %{state | + bindings: new_bindings, + history: [source | state.history], + counter: state.counter + 1 + } + + {:ok, result_str, new_state} + + {:error, errors} -> + error_str = format_errors(errors) + new_state = %{state | counter: state.counter + 1} + {:error, error_str, new_state} + end + end + + @doc "Check if input has balanced delimiters (parens, brackets, braces)" + def balanced?(input) do + input + |> String.graphemes() + |> count_delimiters(0, 0, 0, false, false) + end + + # Count open/close delimiters, respecting strings and comments + defp count_delimiters([], parens, brackets, braces, _in_string, _escape) do + parens == 0 and brackets == 0 and braces == 0 + end + + defp count_delimiters([char | rest], p, b, br, in_string, escape) do + cond do + escape -> + # Previous char was \, skip this one + count_delimiters(rest, p, b, br, in_string, false) + + char == "\\" and in_string -> + count_delimiters(rest, p, b, br, in_string, true) + + char == "\"" and not in_string -> + count_delimiters(rest, p, b, br, true, false) + + char == "\"" and in_string -> + count_delimiters(rest, p, b, br, false, false) + + in_string -> + count_delimiters(rest, p, b, br, true, false) + + char == ";" -> + # Comment - skip rest of line + rest_after_newline = Enum.drop_while(rest, &(&1 != "\n")) + count_delimiters(rest_after_newline, p, b, br, false, false) + + char == "(" -> count_delimiters(rest, p + 1, b, br, false, false) + char == ")" -> count_delimiters(rest, p - 1, b, br, false, false) + char == "[" -> count_delimiters(rest, p, b + 1, br, false, false) + char == "]" -> count_delimiters(rest, p, b - 1, br, false, false) + char == "{" -> count_delimiters(rest, p, b, br + 1, false, false) + char == "}" -> count_delimiters(rest, p, b, br - 1, false, false) + + true -> count_delimiters(rest, p, b, br, false, false) + end + end + + defp format_errors(errors) when is_list(errors) do + Enum.map_join(errors, "\n", fn + %{message: msg, line: line} when is_integer(line) and line > 0 -> + "Error on line #{line}: #{msg}" + + %{message: msg} -> + "Error: #{msg}" + + other -> + "Error: #{inspect(other)}" + end) + end + + defp format_errors(other) do + "Error: #{inspect(other)}" + end +end diff --git a/lib/clj_elixir/transformer.ex b/lib/clj_elixir/transformer.ex index 1bc7146..5f46f1e 100644 --- a/lib/clj_elixir/transformer.ex +++ b/lib/clj_elixir/transformer.ex @@ -199,12 +199,12 @@ defmodule CljElixir.Transformer do # --------------------------------------------------------------------------- # Dynamic vars - defp transform_symbol("*self*", _meta, ctx) do - {{:self, [], []}, ctx} + defp transform_symbol("*self*", meta, ctx) do + {{:self, elixir_meta(meta), []}, ctx} end - defp transform_symbol("*node*", _meta, ctx) do - {{:node, [], []}, ctx} + defp transform_symbol("*node*", meta, ctx) do + {{:node, elixir_meta(meta), []}, ctx} end # true/false/nil are also possible as symbols @@ -213,10 +213,10 @@ defmodule CljElixir.Transformer do defp transform_symbol("nil", _meta, ctx), do: {nil, ctx} # Module-qualified symbol like Enum/map or io/format - defp transform_symbol(name, _meta, ctx) when is_binary(name) do + defp transform_symbol(name, meta, ctx) when is_binary(name) do cond do String.contains?(name, "/") -> - transform_module_call_symbol(name, ctx) + transform_module_call_symbol(name, meta, ctx) module_reference?(name) -> # Bare module reference (e.g., CljElixir.SubVector as a value) @@ -226,7 +226,7 @@ defmodule CljElixir.Transformer do # Plain variable munged = munge_name(name) atom_name = String.to_atom(munged) - {{atom_name, [], nil}, ctx} + {{atom_name, elixir_meta(meta), nil}, ctx} end end @@ -239,10 +239,11 @@ defmodule CljElixir.Transformer do String.contains?(name, ".") end - defp transform_module_call_symbol(name, ctx) do + defp transform_module_call_symbol(name, meta, ctx) do {mod_ast, fun_atom} = parse_module_function(name) + em = elixir_meta(meta) # Bare qualified symbol → zero-arg call (e.g., Enum/count as value) - ast = {{:., [], [mod_ast, fun_atom]}, [], []} + ast = {{:., em, [mod_ast, fun_atom]}, em, []} {ast, ctx} end @@ -317,6 +318,10 @@ defmodule CljElixir.Transformer do {:symbol, _, "dec"} -> transform_dec(args, ctx) {:symbol, _, "str"} -> transform_str(args, ctx) {:symbol, _, "println"} -> transform_println(args, ctx) + {:symbol, _, "pr-str"} -> transform_pr_str(args, ctx) + {:symbol, _, "pr"} -> transform_pr(args, ctx) + {:symbol, _, "prn"} -> transform_prn(args, ctx) + {:symbol, _, "print-str"} -> transform_print_str(args, ctx) {:symbol, _, "nil?"} -> transform_nil_check(args, ctx) {:symbol, _, "throw"} -> transform_throw(args, ctx) {:symbol, _, "count"} -> transform_count(args, ctx) @@ -382,7 +387,7 @@ defmodule CljElixir.Transformer do # --- Keyword-as-function: (:name user) --- kw when is_atom(kw) -> - transform_keyword_call(kw, args, ctx) + transform_keyword_call(kw, args, meta, ctx) # --- Module/function calls from symbol with / --- {:symbol, _, name} when is_binary(name) -> @@ -390,18 +395,18 @@ defmodule CljElixir.Transformer do expand_macro(name, args, ctx) else if String.contains?(name, "/") do - transform_module_call(name, args, ctx) + transform_module_call(name, args, meta, ctx) else # Check for ->Constructor and map->Constructor cond do String.starts_with?(name, "->") and not String.starts_with?(name, "->>") -> - transform_positional_constructor(name, args, ctx) + transform_positional_constructor(name, args, meta, ctx) String.starts_with?(name, "map->") -> - transform_map_constructor(name, args, ctx) + transform_map_constructor(name, args, meta, ctx) true -> - transform_unqualified_call(name, args, ctx) + transform_unqualified_call(name, args, meta, ctx) end end end @@ -418,7 +423,7 @@ defmodule CljElixir.Transformer do # 1. defmodule # --------------------------------------------------------------------------- - defp transform_defmodule(args, _meta, ctx) do + defp transform_defmodule(args, meta, ctx) do {name_form, rest} = extract_name(args) mod_alias = module_name_ast(name_form) @@ -459,7 +464,7 @@ defmodule CljElixir.Transformer do end ast = - {:defmodule, [context: Elixir], + {:defmodule, [context: Elixir] ++ elixir_meta(meta), [mod_alias, [do: block]]} {ast, ctx} @@ -469,7 +474,7 @@ defmodule CljElixir.Transformer do # 2. defn / defn- # --------------------------------------------------------------------------- - defp transform_defn(args, _meta, ctx, kind) do + defp transform_defn(args, meta, ctx, kind) do {name_form, rest} = extract_name(args) fun_name = symbol_to_atom(name_form) @@ -485,6 +490,7 @@ defmodule CljElixir.Transformer do clauses = parse_defn_clauses(rest) def_kind = if kind == :def, do: :def, else: :defp + em = elixir_meta(meta) doc_ast = if doc do @@ -519,12 +525,12 @@ defmodule CljElixir.Transformer do clause = case guard do nil -> - {def_kind, [], [call_with_args(fun_name, param_asts), [do: body_ast]]} + {def_kind, em, [call_with_args(fun_name, param_asts), [do: body_ast]]} guard_form -> guard_ast = transform(guard_form, fn_ctx) - {def_kind, [], + {def_kind, em, [ {:when, [], [call_with_args(fun_name, param_asts), guard_ast]}, @@ -600,8 +606,9 @@ defmodule CljElixir.Transformer do # 3. fn # --------------------------------------------------------------------------- - defp transform_fn(args, _meta, ctx) do + defp transform_fn(args, meta, ctx) do clauses = parse_fn_clauses(args) + em = elixir_meta(meta) fn_clauses = Enum.flat_map(clauses, fn {params, rest_param, guard, body_forms} -> @@ -632,7 +639,7 @@ defmodule CljElixir.Transformer do [clause] end) - {{:fn, [], fn_clauses}, ctx} + {{:fn, em, fn_clauses}, ctx} end defp parse_fn_clauses(args) do @@ -739,15 +746,16 @@ defmodule CljElixir.Transformer do # 5. let # --------------------------------------------------------------------------- - defp transform_let([{:vector, _, bindings} | body], _meta, ctx) do + defp transform_let([{:vector, _, bindings} | body], meta, ctx) do binding_pairs = Enum.chunk_every(bindings, 2) + em = elixir_meta(meta) {binding_asts, final_ctx} = Enum.map_reduce(binding_pairs, ctx, fn [pattern, expr], acc -> pattern_ctx = %{acc | in_pattern: true} pat_ast = transform(pattern, pattern_ctx) expr_ast = transform(expr, acc) - match_ast = {:=, [], [pat_ast, expr_ast]} + match_ast = {:=, em, [pat_ast, expr_ast]} {match_ast, acc} end) @@ -757,7 +765,7 @@ defmodule CljElixir.Transformer do ast = case all do [single] -> single - multiple -> {:__block__, [], multiple} + multiple -> {:__block__, em, multiple} end {ast, ctx} @@ -767,7 +775,7 @@ defmodule CljElixir.Transformer do # 6. if / when / cond / case / do # --------------------------------------------------------------------------- - defp transform_if([test, then_form | else_form], _meta, ctx) do + defp transform_if([test, then_form | else_form], meta, ctx) do test_ast = transform(test, ctx) then_ast = transform(then_form, ctx) @@ -777,18 +785,18 @@ defmodule CljElixir.Transformer do [] -> nil end - ast = {:if, [], [test_ast, [do: then_ast, else: else_ast]]} + ast = {:if, elixir_meta(meta), [test_ast, [do: then_ast, else: else_ast]]} {ast, ctx} end - defp transform_when([test | body], _meta, ctx) do + defp transform_when([test | body], meta, ctx) do test_ast = transform(test, ctx) body_ast = transform_body(body, ctx) - ast = {:if, [], [test_ast, [do: body_ast]]} + ast = {:if, elixir_meta(meta), [test_ast, [do: body_ast]]} {ast, ctx} end - defp transform_cond(pairs, _meta, ctx) do + defp transform_cond(pairs, meta, ctx) do clauses = pairs |> Enum.chunk_every(2) @@ -803,11 +811,11 @@ defmodule CljElixir.Transformer do {:->, [], [[cond_ast], result_ast]} end) - ast = {:cond, [], [[do: clauses]]} + ast = {:cond, elixir_meta(meta), [[do: clauses]]} {ast, ctx} end - defp transform_case([val | clauses], _meta, ctx) do + defp transform_case([val | clauses], meta, ctx) do val_ast = transform(val, ctx) pattern_ctx = %{ctx | in_pattern: true} @@ -828,11 +836,12 @@ defmodule CljElixir.Transformer do {:->, [], [[pat_ast], body_ast]} end) - ast = {:case, [], [val_ast, [do: case_clauses]]} + ast = {:case, elixir_meta(meta), [val_ast, [do: case_clauses]]} {ast, ctx} end defp transform_do(exprs, _meta, ctx) do + # transform_body handles its own block wrapping; meta not needed here body_ast = transform_body(exprs, ctx) {body_ast, ctx} end @@ -841,9 +850,10 @@ defmodule CljElixir.Transformer do # 7. loop / recur # --------------------------------------------------------------------------- - defp transform_loop([{:vector, _, bindings} | body], _meta, ctx) do + defp transform_loop([{:vector, _, bindings} | body], meta, ctx) do binding_pairs = Enum.chunk_every(bindings, 2) arity = length(binding_pairs) + em = elixir_meta(meta) loop_var = unique_var(:loop_fn, ctx) loop_ctx = %{ctx | loop_var: loop_var, loop_arity: arity} @@ -859,16 +869,16 @@ defmodule CljElixir.Transformer do # Pattern: loop_fn = fn loop_fn, bindings... -> body end; loop_fn.(loop_fn, init_vals...) fn_params = [loop_var | param_names] - fn_ast = {:fn, [], [{:->, [], [fn_params, body_ast]}]} + fn_ast = {:fn, em, [{:->, [], [fn_params, body_ast]}]} - assign = {:=, [], [loop_var, fn_ast]} - invoke = {{:., [], [loop_var]}, [], [loop_var | init_vals]} + assign = {:=, em, [loop_var, fn_ast]} + invoke = {{:., em, [loop_var]}, em, [loop_var | init_vals]} - ast = {:__block__, [], [assign, invoke]} + ast = {:__block__, em, [assign, invoke]} {ast, ctx} end - defp transform_recur(args, _meta, ctx) do + defp transform_recur(args, _meta_unused, ctx) do t_args = Enum.map(args, fn a -> transform(a, ctx) end) ast = @@ -891,21 +901,22 @@ defmodule CljElixir.Transformer do # 8. def (top-level binding) # --------------------------------------------------------------------------- - defp transform_def([name_form, value], _meta, ctx) do + defp transform_def([name_form, value], meta, ctx) do fun_name = symbol_to_atom(name_form) + em = elixir_meta(meta) # Check if value looks like a schema definition case detect_schema(value) do nil -> val_ast = transform(value, ctx) - def_ast = {:def, [], [{fun_name, [], []}, [do: val_ast]]} + def_ast = {:def, em, [{fun_name, [], []}, [do: val_ast]]} {def_ast, ctx} schema_data -> # For schema defs, use the plain data as the runtime value # (avoids trying to transform schema references like PositiveInt as variables) val_ast = Macro.escape(schema_data) - def_ast = {:def, [], [{fun_name, [], []}, [do: val_ast]]} + def_ast = {:def, em, [{fun_name, [], []}, [do: val_ast]]} # Generate type name: "User" -> :user, "PositiveInt" -> :positive_int schema_name = symbol_name(name_form) @@ -931,9 +942,10 @@ defmodule CljElixir.Transformer do end end - defp transform_def([name_form], _meta, ctx) do + defp transform_def([name_form], meta, ctx) do fun_name = symbol_to_atom(name_form) - ast = {:def, [], [{fun_name, [], []}, [do: nil]]} + em = elixir_meta(meta) + ast = {:def, em, [{fun_name, [], []}, [do: nil]]} {ast, ctx} end @@ -941,10 +953,11 @@ defmodule CljElixir.Transformer do # 9. Module/function calls (FFI) # --------------------------------------------------------------------------- - defp transform_module_call(name, args, ctx) do + defp transform_module_call(name, args, meta, ctx) do {mod_ast, fun_atom} = parse_module_function(name) t_args = Enum.map(args, fn a -> transform(a, ctx) end) - ast = {{:., [], [mod_ast, fun_atom]}, [], t_args} + em = elixir_meta(meta) + ast = {{:., em, [mod_ast, fun_atom]}, em, t_args} {ast, ctx} end @@ -982,11 +995,11 @@ defmodule CljElixir.Transformer do # 10. Unqualified function calls # --------------------------------------------------------------------------- - defp transform_unqualified_call(name, args, ctx) do + defp transform_unqualified_call(name, args, meta, ctx) do munged = munge_name(name) fun_atom = String.to_atom(munged) t_args = Enum.map(args, fn a -> transform(a, ctx) end) - ast = {fun_atom, [], t_args} + ast = {fun_atom, elixir_meta(meta), t_args} {ast, ctx} end @@ -1192,7 +1205,7 @@ defmodule CljElixir.Transformer do # 12. Keyword-as-function # --------------------------------------------------------------------------- - defp transform_keyword_call(kw, args, ctx) do + defp transform_keyword_call(kw, args, _meta, ctx) do case args do [map_form] -> map_ast = transform(map_form, ctx) @@ -1215,7 +1228,7 @@ defmodule CljElixir.Transformer do # 13. defprotocol # --------------------------------------------------------------------------- - defp transform_defprotocol(args, _meta, ctx) do + defp transform_defprotocol(args, meta, ctx) do {name_form, rest} = extract_name(args) proto_alias = module_name_ast(name_form) @@ -1265,7 +1278,7 @@ defmodule CljElixir.Transformer do multiple -> {:__block__, [], multiple} end - ast = {:defprotocol, [context: Elixir], [proto_alias, [do: block]]} + ast = {:defprotocol, [context: Elixir] ++ elixir_meta(meta), [proto_alias, [do: block]]} {ast, ctx} end @@ -1273,7 +1286,7 @@ defmodule CljElixir.Transformer do # 14. defrecord # --------------------------------------------------------------------------- - defp transform_defrecord(args, _meta, ctx) do + defp transform_defrecord(args, meta, ctx) do {name_form, rest} = extract_name(args) record_alias = module_name_ast(name_form) record_name = symbol_name(name_form) @@ -1348,7 +1361,7 @@ defmodule CljElixir.Transformer do multiple -> {:__block__, [], multiple} end - ast = {:defmodule, [context: Elixir], [record_alias, [do: block]]} + ast = {:defmodule, [context: Elixir] ++ elixir_meta(meta), [record_alias, [do: block]]} {ast, new_ctx} end @@ -1374,7 +1387,7 @@ defmodule CljElixir.Transformer do # 15. extend-type / extend-protocol # --------------------------------------------------------------------------- - defp transform_extend_type([type_form | rest], _meta, ctx) do + defp transform_extend_type([type_form | rest], _meta_unused, ctx) do type_alias = resolve_type_name(type_form) groups = parse_protocol_groups(rest) @@ -1402,7 +1415,7 @@ defmodule CljElixir.Transformer do {ast, ctx} end - defp transform_extend_protocol([proto_form | rest], _meta, ctx) do + defp transform_extend_protocol([proto_form | rest], _meta_unused, ctx) do proto_alias = resolve_type_name(proto_form) # Parse: TypeName (fn ...) TypeName (fn ...) @@ -1435,7 +1448,7 @@ defmodule CljElixir.Transformer do # 16. reify # --------------------------------------------------------------------------- - defp transform_reify(args, _meta, ctx) do + defp transform_reify(args, _meta_unused, ctx) do {counter, new_ctx} = bump_gensym(ctx) mod_name = String.to_atom("CljElixir.Reify_#{counter}") mod_alias = {:__aliases__, [alias: false], [mod_name]} @@ -1475,7 +1488,7 @@ defmodule CljElixir.Transformer do # 17. with # --------------------------------------------------------------------------- - defp transform_with([{:vector, _, bindings} | body_and_else], _meta, ctx) do + defp transform_with([{:vector, _, bindings} | body_and_else], meta, ctx) do binding_pairs = Enum.chunk_every(bindings, 2) pattern_ctx = %{ctx | in_pattern: true} @@ -1507,7 +1520,7 @@ defmodule CljElixir.Transformer do [do: body_ast, else: else_pairs] end - ast = {:with, [], with_clauses ++ [opts]} + ast = {:with, elixir_meta(meta), with_clauses ++ [opts]} {ast, ctx} end @@ -1522,7 +1535,7 @@ defmodule CljElixir.Transformer do # 18. receive # --------------------------------------------------------------------------- - defp transform_receive(clauses, _meta, ctx) do + defp transform_receive(clauses, meta, ctx) do pattern_ctx = %{ctx | in_pattern: true} {case_clauses, after_clause} = parse_receive_clauses(clauses) @@ -1556,7 +1569,7 @@ defmodule CljElixir.Transformer do opts ++ [after: [{:->, [], [[timeout_ast], body_ast]}]] end - ast = {:receive, [], [opts]} + ast = {:receive, elixir_meta(meta), [opts]} {ast, ctx} end @@ -1628,21 +1641,21 @@ defmodule CljElixir.Transformer do # 19. for / doseq # --------------------------------------------------------------------------- - defp transform_for([{:vector, _, bindings} | body], _meta, ctx) do + defp transform_for([{:vector, _, bindings} | body], meta, ctx) do {generators, filters} = parse_comprehension_bindings(bindings, ctx) body_ast = transform_body(body, ctx) args = generators ++ filters ++ [[do: body_ast]] - ast = {:for, [], args} + ast = {:for, elixir_meta(meta), args} {ast, ctx} end - defp transform_doseq([{:vector, _, bindings} | body], _meta, ctx) do + defp transform_doseq([{:vector, _, bindings} | body], meta, ctx) do {generators, filters} = parse_comprehension_bindings(bindings, ctx) body_ast = transform_body(body, ctx) args = generators ++ filters ++ [[do: body_ast]] - ast = {:for, [], args} + ast = {:for, elixir_meta(meta), args} {ast, ctx} end @@ -1991,6 +2004,111 @@ defmodule CljElixir.Transformer do {ast, ctx} end + # (pr-str val) -> CljElixir.Printer.pr_str(val) + # (pr-str a b c) -> join pr_str of each with space + defp transform_pr_str(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + printer_mod = {:__aliases__, [alias: false], [:CljElixir, :Printer]} + + case t_args do + [single] -> + ast = {{:., [], [printer_mod, :pr_str]}, [], [single]} + {ast, ctx} + + multiple -> + strs = + Enum.map(multiple, fn a -> + {{:., [], [printer_mod, :pr_str]}, [], [a]} + end) + + joined = + Enum.reduce(tl(strs), hd(strs), fn s, acc -> + {:<>, [], [acc, {:<>, [], [" ", s]}]} + end) + + {joined, ctx} + end + end + + # (pr val) -> IO.write(CljElixir.Printer.pr_str(val)) + # Multiple args separated by spaces + defp transform_pr(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + printer_mod = {:__aliases__, [alias: false], [:CljElixir, :Printer]} + io_mod = {:__aliases__, [alias: false], [:IO]} + + writes = + t_args + |> Enum.with_index() + |> Enum.flat_map(fn {a, i} -> + pr_call = {{:., [], [printer_mod, :pr_str]}, [], [a]} + write_call = {{:., [], [io_mod, :write]}, [], [pr_call]} + + if i > 0 do + space_call = {{:., [], [io_mod, :write]}, [], [" "]} + [space_call, write_call] + else + [write_call] + end + end) + + ast = {:__block__, [], writes} + {ast, ctx} + end + + # (prn val) -> IO.puts(CljElixir.Printer.pr_str(val)) + # Multiple args joined with spaces, then newline + defp transform_prn(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + printer_mod = {:__aliases__, [alias: false], [:CljElixir, :Printer]} + io_mod = {:__aliases__, [alias: false], [:IO]} + + strs = + Enum.map(t_args, fn a -> + {{:., [], [printer_mod, :pr_str]}, [], [a]} + end) + + joined = + case strs do + [single] -> + single + + multiple -> + Enum.reduce(tl(multiple), hd(multiple), fn s, acc -> + {:<>, [], [acc, {:<>, [], [" ", s]}]} + end) + end + + ast = {{:., [], [io_mod, :puts]}, [], [joined]} + {ast, ctx} + end + + # (print-str val) -> CljElixir.Printer.print_str(val) + # (print-str a b c) -> join print_str of each with space + defp transform_print_str(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + printer_mod = {:__aliases__, [alias: false], [:CljElixir, :Printer]} + + case t_args do + [single] -> + ast = {{:., [], [printer_mod, :print_str]}, [], [single]} + {ast, ctx} + + multiple -> + strs = + Enum.map(multiple, fn a -> + {{:., [], [printer_mod, :print_str]}, [], [a]} + end) + + joined = + Enum.reduce(tl(strs), hd(strs), fn s, acc -> + {:<>, [], [acc, {:<>, [], [" ", s]}]} + end) + + {joined, ctx} + end + end + # nil? defp transform_nil_check([a], ctx) do a_ast = transform(a, ctx) @@ -2034,25 +2152,27 @@ defmodule CljElixir.Transformer do # Positional constructor: (->Name arg1 arg2 ...) # --------------------------------------------------------------------------- - defp transform_positional_constructor(name, args, ctx) do + defp transform_positional_constructor(name, args, meta, ctx) do # "->Name" → strip -> record_name = String.slice(name, 2..-1//1) mod_alias = parse_module_name(record_name) t_args = Enum.map(args, fn a -> transform(a, ctx) end) + em = elixir_meta(meta) # Call Module.new(args...) - ast = {{:., [], [mod_alias, :new]}, [], t_args} + ast = {{:., em, [mod_alias, :new]}, em, t_args} {ast, ctx} end # Map constructor: (map->Name {:field val ...}) - defp transform_map_constructor(name, args, ctx) do + defp transform_map_constructor(name, args, meta, ctx) do record_name = String.slice(name, 5..-1//1) mod_alias = parse_module_name(record_name) t_args = Enum.map(args, fn a -> transform(a, ctx) end) + em = elixir_meta(meta) # Use Kernel.struct!/2 - ast = {{:., [], [{:__aliases__, [alias: false], [:Kernel]}, :struct!]}, [], [mod_alias | t_args]} + ast = {{:., em, [{:__aliases__, [alias: false], [:Kernel]}, :struct!]}, em, [mod_alias | t_args]} {ast, ctx} end @@ -2822,7 +2942,7 @@ defmodule CljElixir.Transformer do # --------------------------------------------------------------------------- # (try body... (catch ...) ... (finally ...)) - defp transform_try(args, _meta, ctx) do + defp transform_try(args, meta, ctx) do {body_forms, catch_clauses, finally_clause} = partition_try_args(args) # Transform body @@ -2858,7 +2978,7 @@ defmodule CljElixir.Transformer do try_opts ++ [after: after_ast] end - ast = {:try, [], [try_opts]} + ast = {:try, elixir_meta(meta), [try_opts]} {ast, ctx} end @@ -3274,4 +3394,17 @@ defmodule CljElixir.Transformer do |> String.replace("-", "_") |> String.to_atom() end + + # --------------------------------------------------------------------------- + # Source-mapping: CljElixir meta → Elixir AST metadata + # --------------------------------------------------------------------------- + + # Convert CljElixir meta map to Elixir AST keyword list metadata. + # Line must be >= 1 for the Erlang compiler annotation layer; + # a zero line/col is treated as absent. + defp elixir_meta(%{line: line, col: col}) when line > 0 and col > 0, + do: [line: line, column: col] + defp elixir_meta(%{line: line}) when line > 0, + do: [line: line] + defp elixir_meta(_), do: [] end diff --git a/lib/mix/tasks/clje.nrepl.ex b/lib/mix/tasks/clje.nrepl.ex new file mode 100644 index 0000000..c98ca76 --- /dev/null +++ b/lib/mix/tasks/clje.nrepl.ex @@ -0,0 +1,36 @@ +defmodule Mix.Tasks.Clje.Nrepl do + @moduledoc """ + Starts an nREPL server for CljElixir. + + ## Usage + + mix clje.nrepl # random port + mix clje.nrepl --port 7888 # specific port + + Writes port to `.nrepl-port` file for editor auto-discovery. + """ + + use Mix.Task + + @shortdoc "Start a CljElixir nREPL server" + + @impl Mix.Task + def run(args) do + {opts, _, _} = OptionParser.parse(args, strict: [port: :integer]) + port = Keyword.get(opts, :port, 0) + + Mix.Task.run("compile") + Mix.Task.run("app.start") + + {:ok, server} = CljElixir.NRepl.Server.start_link(port: port) + actual_port = CljElixir.NRepl.Server.port(server) + + File.write!(".nrepl-port", Integer.to_string(actual_port)) + + IO.puts("nREPL server started on port #{actual_port} on host 127.0.0.1") + IO.puts("Port written to .nrepl-port") + IO.puts("Press Ctrl+C to stop\n") + + Process.sleep(:infinity) + end +end diff --git a/lib/mix/tasks/clje.repl.ex b/lib/mix/tasks/clje.repl.ex new file mode 100644 index 0000000..484424b --- /dev/null +++ b/lib/mix/tasks/clje.repl.ex @@ -0,0 +1,136 @@ +defmodule Mix.Tasks.Clje.Repl do + @moduledoc """ + Starts an interactive CljElixir REPL. + + ## Usage + + mix clje.repl + + Features: + - Full line editing (arrow keys, home/end, backspace) + - Multi-line input (auto-detects unbalanced parens) + - `pr-str` output formatting + - Bindings persist across evaluations + - Special commands: :quit, :history, :help + + For readline-style history (up/down arrow recall), run with rlwrap: + + rlwrap mix clje.repl + """ + + use Mix.Task + + @shortdoc "Start an interactive CljElixir REPL" + + @impl Mix.Task + def run(_args) do + # Ensure the project is compiled first + Mix.Task.run("compile") + + # Start the application + Mix.Task.run("app.start") + + IO.puts("CljElixir REPL v0.1.0") + IO.puts("Type :help for help, :quit to exit\n") + + state = CljElixir.REPL.new() + loop(state) + end + + defp loop(state) do + prompt = "clje:#{state.counter}> " + + case read_input(prompt) do + :eof -> + IO.puts("\nBye!") + + input -> + input = String.trim(input) + + case input do + ":quit" -> + IO.puts("Bye!") + + ":history" -> + print_history(state) + loop(state) + + ":help" -> + print_help() + loop(state) + + "" -> + loop(state) + + _ -> + # Check for multi-line - read more if unbalanced + full_input = maybe_read_more(input) + + case CljElixir.REPL.eval(full_input, state) do + {:ok, result_str, new_state} -> + IO.puts(result_str) + loop(new_state) + + {:error, error_str, new_state} -> + IO.puts("\e[31m#{error_str}\e[0m") + loop(new_state) + end + end + end + end + + defp read_input(prompt) do + case IO.gets(prompt) do + :eof -> :eof + {:error, _} -> :eof + data -> data + end + end + + defp maybe_read_more(input) do + if CljElixir.REPL.balanced?(input) do + input + else + read_continuation(input) + end + end + + defp read_continuation(acc) do + case IO.gets(" ") do + :eof -> acc + {:error, _} -> acc + line -> + new_acc = acc <> "\n" <> String.trim_trailing(line, "\n") + + if CljElixir.REPL.balanced?(new_acc) do + new_acc + else + read_continuation(new_acc) + end + end + end + + defp print_history(state) do + state.history + |> Enum.reverse() + |> Enum.with_index(1) + |> Enum.each(fn {expr, i} -> + IO.puts(" #{i}: #{String.replace(expr, "\n", "\n ")}") + end) + end + + defp print_help do + IO.puts(""" + CljElixir REPL Commands: + :help - show this help + :history - show expression history + :quit - exit the REPL + + Tips: + - Multi-line input: unbalanced parens trigger continuation + - Bindings persist: (def x 42) makes x available in later expressions + - All CljElixir syntax is supported + - For up/down arrow history, run: rlwrap mix clje.repl + """) + end +end diff --git a/src/clje/core/protocols.clje b/src/clje/core/protocols.clje index 2ef27e7..85aff8c 100644 --- a/src/clje/core/protocols.clje +++ b/src/clje/core/protocols.clje @@ -55,6 +55,9 @@ (defprotocol CljElixir.IEquiv (-equiv [o other])) +(defprotocol CljElixir.IPrintWithWriter + (-pr-writer [o writer opts])) + (defprotocol CljElixir.IClojurify (-clojurify [o])) diff --git a/test/clj_elixir/nrepl_test.exs b/test/clj_elixir/nrepl_test.exs new file mode 100644 index 0000000..f487051 --- /dev/null +++ b/test/clj_elixir/nrepl_test.exs @@ -0,0 +1,342 @@ +defmodule CljElixir.NReplTest do + use ExUnit.Case, async: false + + alias CljElixir.NRepl.{Bencode, Server, SessionManager, Handler} + + # --- Bencode Tests --- + + describe "Bencode encoding" do + test "encode string" do + assert Bencode.encode("hello") == "5:hello" + assert Bencode.encode("") == "0:" + end + + test "encode integer" do + assert Bencode.encode(42) == "i42e" + assert Bencode.encode(0) == "i0e" + assert Bencode.encode(-7) == "i-7e" + end + + test "encode list" do + assert Bencode.encode([1, 2, 3]) == "li1ei2ei3ee" + assert Bencode.encode([]) == "le" + assert Bencode.encode(["hello", 42]) == "l5:helloi42ee" + end + + test "encode dict" do + assert Bencode.encode(%{"op" => "eval"}) == "d2:op4:evale" + end + + test "encode nested" do + msg = %{"op" => "eval", "code" => "(+ 1 2)", "id" => "1"} + encoded = Bencode.encode(msg) + assert is_binary(encoded) + assert String.starts_with?(encoded, "d") + assert String.ends_with?(encoded, "e") + end + + test "encode atom keys" do + assert Bencode.encode(%{op: "eval"}) == "d2:op4:evale" + end + end + + describe "Bencode decoding" do + test "decode string" do + assert Bencode.decode("5:hello") == "hello" + assert Bencode.decode("0:") == "" + end + + test "decode integer" do + assert Bencode.decode("i42e") == 42 + assert Bencode.decode("i-7e") == -7 + end + + test "decode list" do + assert Bencode.decode("li1ei2ei3ee") == [1, 2, 3] + end + + test "decode dict" do + result = Bencode.decode("d2:op4:evale") + assert result == %{"op" => "eval"} + end + + test "roundtrip" do + original = %{"op" => "eval", "code" => "(+ 1 2)", "id" => "msg-1"} + assert Bencode.decode(Bencode.encode(original)) == original + end + + test "roundtrip nested" do + original = %{"ops" => %{"eval" => %{}, "clone" => %{}}, "status" => ["done"]} + assert Bencode.decode(Bencode.encode(original)) == original + end + end + + # --- Session Manager Tests --- + + describe "SessionManager" do + setup do + {:ok, manager} = SessionManager.start_link() + %{manager: manager} + end + + test "create session", %{manager: manager} do + id = SessionManager.create_session(manager) + assert is_binary(id) + assert String.length(id) > 0 + end + + test "list sessions", %{manager: manager} do + assert SessionManager.list_sessions(manager) == [] + id = SessionManager.create_session(manager) + assert SessionManager.list_sessions(manager) == [id] + end + + test "eval in session", %{manager: manager} do + id = SessionManager.create_session(manager) + assert {:ok, "3"} = SessionManager.eval(manager, id, "(+ 1 2)") + end + + test "session state persists", %{manager: manager} do + id = SessionManager.create_session(manager) + + {:ok, _} = + SessionManager.eval(manager, id, """ + (defmodule NReplSessionTest + (defn hello [] :hi)) + """) + + {:ok, result} = SessionManager.eval(manager, id, "(NReplSessionTest/hello)") + assert result == ":hi" + end + + test "close session", %{manager: manager} do + id = SessionManager.create_session(manager) + assert :ok = SessionManager.close_session(manager, id) + assert SessionManager.list_sessions(manager) == [] + end + + test "independent sessions", %{manager: manager} do + id1 = SessionManager.create_session(manager) + id2 = SessionManager.create_session(manager) + assert id1 != id2 + {:ok, "3"} = SessionManager.eval(manager, id1, "(+ 1 2)") + {:ok, "7"} = SessionManager.eval(manager, id2, "(+ 3 4)") + end + end + + # --- Handler Tests --- + + describe "Handler" do + setup do + {:ok, manager} = SessionManager.start_link() + %{manager: manager} + end + + test "clone creates session", %{manager: manager} do + [response] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager) + assert response["status"] == ["done"] + assert is_binary(response["new-session"]) + end + + test "describe returns ops", %{manager: manager} do + [response] = Handler.handle(%{"op" => "describe", "id" => "1"}, manager) + assert response["status"] == ["done"] + assert is_map(response["ops"]) + assert Map.has_key?(response["ops"], "eval") + assert Map.has_key?(response["ops"], "clone") + end + + test "eval returns value", %{manager: manager} do + [clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager) + session = clone_resp["new-session"] + + responses = + Handler.handle( + %{ + "op" => "eval", + "code" => "(+ 1 2)", + "session" => session, + "id" => "2" + }, + manager + ) + + # Should have value response and done response + values = Enum.filter(responses, &Map.has_key?(&1, "value")) + dones = Enum.filter(responses, fn r -> r["status"] == ["done"] end) + + assert length(values) == 1 + assert hd(values)["value"] == "3" + assert length(dones) == 1 + end + + test "eval captures stdout", %{manager: manager} do + [clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager) + session = clone_resp["new-session"] + + responses = + Handler.handle( + %{ + "op" => "eval", + "code" => "(println \"hello from nrepl\")", + "session" => session, + "id" => "2" + }, + manager + ) + + outs = Enum.filter(responses, &Map.has_key?(&1, "out")) + assert length(outs) >= 1 + assert hd(outs)["out"] =~ "hello from nrepl" + end + + test "eval error", %{manager: manager} do + [clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager) + session = clone_resp["new-session"] + + responses = + Handler.handle( + %{ + "op" => "eval", + "code" => "(defmodule HandlerErrTest (bad-syntax", + "session" => session, + "id" => "2" + }, + manager + ) + + errs = Enum.filter(responses, &Map.has_key?(&1, "err")) + assert length(errs) >= 1 + end + + test "ls-sessions", %{manager: manager} do + [clone1] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager) + [clone2] = Handler.handle(%{"op" => "clone", "id" => "2"}, manager) + + [response] = Handler.handle(%{"op" => "ls-sessions", "id" => "3"}, manager) + sessions = response["sessions"] + + assert length(sessions) == 2 + assert clone1["new-session"] in sessions + assert clone2["new-session"] in sessions + end + + test "close session", %{manager: manager} do + [clone_resp] = Handler.handle(%{"op" => "clone", "id" => "1"}, manager) + session = clone_resp["new-session"] + + [close_resp] = + Handler.handle( + %{ + "op" => "close", + "session" => session, + "id" => "2" + }, + manager + ) + + assert close_resp["status"] == ["done"] + + [ls_resp] = Handler.handle(%{"op" => "ls-sessions", "id" => "3"}, manager) + assert ls_resp["sessions"] == [] + end + + test "unknown op", %{manager: manager} do + [response] = Handler.handle(%{"op" => "bogus", "id" => "1"}, manager) + assert "unknown-op" in response["status"] + end + end + + # --- TCP Integration Test --- + + describe "TCP server" do + test "server starts and accepts connections" do + {:ok, server} = Server.start_link(port: 0) + port = Server.port(server) + assert port > 0 + + # Connect as a client + {:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false]) + + # Send clone request + clone_msg = Bencode.encode(%{"op" => "clone", "id" => "1"}) + :ok = :gen_tcp.send(socket, clone_msg) + + # Read response + {:ok, data} = :gen_tcp.recv(socket, 0, 5000) + response = Bencode.decode(data) + + assert response["status"] == ["done"] + assert is_binary(response["new-session"]) + + :gen_tcp.close(socket) + GenServer.stop(server) + end + + test "full eval over TCP" do + {:ok, server} = Server.start_link(port: 0) + port = Server.port(server) + + {:ok, socket} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false]) + + # Clone + :ok = :gen_tcp.send(socket, Bencode.encode(%{"op" => "clone", "id" => "1"})) + {:ok, clone_data} = :gen_tcp.recv(socket, 0, 5000) + clone_resp = Bencode.decode(clone_data) + session = clone_resp["new-session"] + + # Eval + eval_msg = + Bencode.encode(%{ + "op" => "eval", + "code" => "(+ 21 21)", + "session" => session, + "id" => "2" + }) + + :ok = :gen_tcp.send(socket, eval_msg) + + # Read all responses (value + done) + responses = read_all_responses(socket) + + values = Enum.filter(responses, &Map.has_key?(&1, "value")) + assert length(values) >= 1 + assert hd(values)["value"] == "42" + + :gen_tcp.close(socket) + GenServer.stop(server) + end + end + + # Helper to read multiple bencode responses + defp read_all_responses(socket, acc \\ [], buffer \\ "") do + case :gen_tcp.recv(socket, 0, 2000) do + {:ok, data} -> + new_buffer = buffer <> data + {msgs, rest} = decode_available(new_buffer) + new_acc = acc ++ msgs + + if Enum.any?(new_acc, fn r -> r["status"] == ["done"] end) do + new_acc + else + read_all_responses(socket, new_acc, rest) + end + + {:error, :timeout} -> + {msgs, _rest} = decode_available(buffer) + acc ++ msgs + + {:error, _} -> + acc + end + end + + defp decode_available(data, acc \\ []) do + try do + {msg, rest} = Bencode.decode_one(data) + decode_available(rest, acc ++ [msg]) + rescue + _ -> {acc, data} + end + end +end diff --git a/test/clj_elixir/phase8_test.exs b/test/clj_elixir/phase8_test.exs new file mode 100644 index 0000000..655e8a6 --- /dev/null +++ b/test/clj_elixir/phase8_test.exs @@ -0,0 +1,342 @@ +defmodule CljElixir.Phase8Test do + use ExUnit.Case, async: false + + defp eval!(source) do + case CljElixir.Compiler.eval_string(source) do + {:ok, result, _} -> result + {:error, errors} -> raise "Compilation failed: #{inspect(errors)}" + end + end + + describe "CljElixir.Printer.pr_str" do + test "nil" do + assert CljElixir.Printer.pr_str(nil) == "nil" + end + + test "booleans" do + assert CljElixir.Printer.pr_str(true) == "true" + assert CljElixir.Printer.pr_str(false) == "false" + end + + test "integers" do + assert CljElixir.Printer.pr_str(42) == "42" + assert CljElixir.Printer.pr_str(-7) == "-7" + end + + test "floats" do + assert CljElixir.Printer.pr_str(3.14) == "3.14" + end + + test "strings are quoted" do + assert CljElixir.Printer.pr_str("hello") == "\"hello\"" + end + + test "strings with escapes" do + assert CljElixir.Printer.pr_str("hello\nworld") == "\"hello\\nworld\"" + assert CljElixir.Printer.pr_str("say \"hi\"") == "\"say \\\"hi\\\"\"" + end + + test "atoms/keywords" do + assert CljElixir.Printer.pr_str(:hello) == ":hello" + assert CljElixir.Printer.pr_str(:ok) == ":ok" + end + + test "lists" do + assert CljElixir.Printer.pr_str([1, 2, 3]) == "(1 2 3)" + assert CljElixir.Printer.pr_str([]) == "()" + end + + test "maps" do + result = CljElixir.Printer.pr_str(%{name: "Ada"}) + assert result =~ ":name" + assert result =~ "\"Ada\"" + assert String.starts_with?(result, "{") + assert String.ends_with?(result, "}") + end + + test "tuples" do + assert CljElixir.Printer.pr_str({:ok, 42}) == "#el[:ok 42]" + end + + test "nested structures" do + result = CljElixir.Printer.pr_str(%{data: [1, 2, {:ok, "hi"}]}) + assert result =~ ":data" + assert result =~ "(1 2 #el[:ok \"hi\"])" + end + + test "MapSet" do + result = CljElixir.Printer.pr_str(MapSet.new([:a, :b])) + assert String.starts_with?(result, "\#{") + assert String.ends_with?(result, "}") + end + + test "module names" do + assert CljElixir.Printer.pr_str(Enum) == "Enum" + assert CljElixir.Printer.pr_str(IO) == "IO" + end + + test "pids" do + result = CljElixir.Printer.pr_str(self()) + assert String.starts_with?(result, "#PID<") + end + end + + describe "CljElixir.Printer.print_str" do + test "strings not quoted" do + assert CljElixir.Printer.print_str("hello") == "hello" + end + + test "non-strings same as pr_str" do + assert CljElixir.Printer.print_str(42) == "42" + assert CljElixir.Printer.print_str(:ok) == ":ok" + end + end + + describe "pr-str builtin" do + test "pr-str on integer" do + result = eval!("(pr-str 42)") + assert result == "42" + end + + test "pr-str on string" do + result = eval!("(pr-str \"hello\")") + assert result == "\"hello\"" + end + + test "pr-str on keyword" do + result = eval!("(pr-str :foo)") + assert result == ":foo" + end + + test "pr-str on list" do + result = eval!("(pr-str (list 1 2 3))") + assert result == "(1 2 3)" + end + + test "pr-str on map" do + result = eval!("(pr-str {:a 1})") + assert result =~ ":a" + assert result =~ "1" + end + + test "pr-str on tuple" do + result = eval!("(pr-str #el[:ok 42])") + assert result == "#el[:ok 42]" + end + + test "pr-str on nil" do + result = eval!("(pr-str nil)") + assert result == "nil" + end + + test "pr-str on boolean" do + result = eval!("(pr-str true)") + assert result == "true" + end + + test "pr-str multiple args joined with space" do + result = eval!("(pr-str 1 2 3)") + assert result == "1 2 3" + end + end + + describe "print-str builtin" do + test "print-str on string (no quotes)" do + result = eval!("(print-str \"hello\")") + assert result == "hello" + end + + test "print-str on integer" do + result = eval!("(print-str 42)") + assert result == "42" + end + + test "print-str multiple args joined with space" do + result = eval!("(print-str \"hello\" \"world\")") + assert result == "hello world" + end + end + + describe "prn builtin" do + test "prn outputs to stdout with newline" do + output = + ExUnit.CaptureIO.capture_io(fn -> + eval!("(prn 42)") + end) + + assert String.trim(output) == "42" + end + + test "prn with string (quoted)" do + output = + ExUnit.CaptureIO.capture_io(fn -> + eval!("(prn \"hello\")") + end) + + assert String.trim(output) == "\"hello\"" + end + + test "prn multiple args" do + output = + ExUnit.CaptureIO.capture_io(fn -> + eval!("(prn 1 2 3)") + end) + + assert String.trim(output) == "1 2 3" + end + end + + describe "pr builtin" do + test "pr outputs to stdout without newline" do + output = + ExUnit.CaptureIO.capture_io(fn -> + eval!("(pr 42)") + end) + + assert output == "42" + end + + test "pr with string (quoted)" do + output = + ExUnit.CaptureIO.capture_io(fn -> + eval!("(pr \"hello\")") + end) + + assert output == "\"hello\"" + end + + test "pr multiple args separated by spaces" do + output = + ExUnit.CaptureIO.capture_io(fn -> + eval!("(pr 1 2 3)") + end) + + assert output == "1 2 3" + end + end + + # --------------------------------------------------------------------------- + # Source-mapped line/col metadata + # --------------------------------------------------------------------------- + + describe "source-mapped metadata" do + test "symbol AST carries line/col from reader" do + {:ok, ast} = CljElixir.Compiler.compile_string("(+ x 1)") + # x should have line: 1 metadata in the Elixir AST + # Walk the AST to find the :x variable + {_, found} = Macro.prewalk(ast, false, fn + {:x, meta, nil} = node, _acc when is_list(meta) -> + {node, meta[:line] == 1} + node, acc -> + {node, acc} + end) + assert found, "variable :x should have line: 1 metadata" + end + + test "defmodule AST carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string("(defmodule Foo (defn bar [] 1))") + # The defmodule node should have line: 1 + {:defmodule, meta, _} = ast + assert meta[:line] == 1 + end + + test "defn AST carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string(""" + (defmodule MetaTest1 + (defn foo [x] x)) + """) + # Find the :def node inside the module body + {_, found_line} = Macro.prewalk(ast, nil, fn + {:def, meta, _} = node, nil when is_list(meta) -> + {node, Keyword.get(meta, :line)} + node, acc -> + {node, acc} + end) + assert found_line == 2, "defn should have line: 2 metadata" + end + + test "if AST carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string("(if true 1 2)") + {:if, meta, _} = ast + assert meta[:line] == 1 + end + + test "fn AST carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string("(fn [x] x)") + {:fn, meta, _} = ast + assert meta[:line] == 1 + end + + test "case AST carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string("(case 1 1 :one 2 :two)") + {:case, meta, _} = ast + assert meta[:line] == 1 + end + + test "cond AST carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string("(cond true :yes)") + {:cond, meta, _} = ast + assert meta[:line] == 1 + end + + test "module call AST carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string("(Enum/map [1 2] inc)") + # The outer call should be {{:., meta, [Enum, :map]}, meta, args} + {{:., dot_meta, _}, call_meta, _} = ast + assert dot_meta[:line] == 1 + assert call_meta[:line] == 1 + end + + test "unqualified call AST carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string(""" + (defmodule MetaTest2 + (defn foo [] (bar 1))) + """) + # Find the :bar call inside the module + {_, found_line} = Macro.prewalk(ast, nil, fn + {:bar, meta, [1]} = node, nil when is_list(meta) -> + {node, meta[:line]} + node, acc -> + {node, acc} + end) + assert found_line == 2, "unqualified call should have line: 2 metadata" + end + + test "runtime error has source location" do + # This tests that evaluated code preserves source info + result = CljElixir.Compiler.eval_string(""" + (defmodule LineTest1 + (defn hello [name] + (str "hello " name))) + (LineTest1/hello "world") + """, file: "line_test.clje") + + assert {:ok, "hello world", _} = result + end + + test "let block carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string("(let [x 1] x)") + # let produces either a block or a single = expression + case ast do + {:__block__, meta, _} -> assert meta[:line] == 1 + {:=, meta, _} -> assert meta[:line] == 1 + end + end + + test "for comprehension carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string("(for [x [1 2 3]] x)") + {:for, meta, _} = ast + assert meta[:line] == 1 + end + + test "try carries line metadata" do + {:ok, ast} = CljElixir.Compiler.compile_string(""" + (try + (throw "oops") + (catch e (str "caught: " e))) + """) + {:try, meta, _} = ast + assert meta[:line] == 1 + end + end +end diff --git a/test/clj_elixir/repl_test.exs b/test/clj_elixir/repl_test.exs new file mode 100644 index 0000000..07ea568 --- /dev/null +++ b/test/clj_elixir/repl_test.exs @@ -0,0 +1,121 @@ +defmodule CljElixir.REPLTest do + use ExUnit.Case, async: true + + alias CljElixir.REPL + + describe "REPL.new" do + test "creates initial state" do + state = REPL.new() + assert state.bindings == [] + assert state.history == [] + assert state.counter == 1 + end + end + + describe "REPL.eval" do + test "evaluates simple expression" do + state = REPL.new() + assert {:ok, "3", _} = REPL.eval("(+ 1 2)", state) + end + + test "pr-str formats output" do + state = REPL.new() + {:ok, result, _} = REPL.eval("\"hello\"", state) + assert result == "\"hello\"" + end + + test "nil result" do + state = REPL.new() + {:ok, result, _} = REPL.eval("nil", state) + assert result == "nil" + end + + test "map result" do + state = REPL.new() + {:ok, result, _} = REPL.eval("{:a 1 :b 2}", state) + assert result =~ ":a" + assert result =~ "1" + end + + test "increments counter" do + state = REPL.new() + {:ok, _, state2} = REPL.eval("1", state) + assert state2.counter == 2 + {:ok, _, state3} = REPL.eval("2", state2) + assert state3.counter == 3 + end + + test "stores history" do + state = REPL.new() + {:ok, _, state2} = REPL.eval("(+ 1 2)", state) + assert state2.history == ["(+ 1 2)"] + {:ok, _, state3} = REPL.eval("(+ 3 4)", state2) + assert state3.history == ["(+ 3 4)", "(+ 1 2)"] + end + + test "error returns error tuple" do + state = REPL.new() + {:error, msg, _} = REPL.eval("(defmodule REPLErrTest (invalid-syntax", state) + assert is_binary(msg) + assert msg =~ "Error" or msg =~ "error" + end + + test "defmodule persists across evals" do + state = REPL.new() + + {:ok, _, state2} = + REPL.eval(""" + (defmodule REPLTestMod + (defn hello [] :hi)) + """, state) + + {:ok, result, _} = REPL.eval("(REPLTestMod/hello)", state2) + assert result == ":hi" + end + + test "tuple result" do + state = REPL.new() + {:ok, result, _} = REPL.eval("#el[:ok 42]", state) + assert result == "#el[:ok 42]" + end + + test "list result" do + state = REPL.new() + {:ok, result, _} = REPL.eval("(list 1 2 3)", state) + assert result == "(1 2 3)" + end + end + + describe "REPL.balanced?" do + test "balanced parens" do + assert REPL.balanced?("(+ 1 2)") + assert REPL.balanced?("(defn foo [x] (+ x 1))") + assert REPL.balanced?("42") + assert REPL.balanced?("") + end + + test "unbalanced parens" do + refute REPL.balanced?("(+ 1 2") + refute REPL.balanced?("(defn foo [x]") + refute REPL.balanced?("(let [x 1") + end + + test "balanced with nested" do + assert REPL.balanced?("(let [x (+ 1 2)] (* x x))") + end + + test "string contents not counted" do + assert REPL.balanced?("(str \"(hello)\")") + assert REPL.balanced?("(str \"[not real]\")") + end + + test "comment contents not counted" do + assert REPL.balanced?("(+ 1 2) ; this has unbalanced (") + end + + test "mixed delimiters" do + assert REPL.balanced?("(let [{:keys [a b]} {:a 1 :b 2}] (+ a b))") + refute REPL.balanced?("(let [{:keys [a b]} {:a 1 :b 2}] (+ a b)") + end + end +end diff --git a/test/clj_elixir/transformer_test.exs b/test/clj_elixir/transformer_test.exs index c4ddecf..251e85f 100644 --- a/test/clj_elixir/transformer_test.exs +++ b/test/clj_elixir/transformer_test.exs @@ -1079,12 +1079,12 @@ defmodule CljElixir.TransformerTest do describe "dynamic vars" do test "*self* produces self() call" do ast = transform("*self*") - assert ast == {:self, [], []} + assert match?({:self, _, []}, ast) end test "*node* produces node() call" do ast = transform("*node*") - assert ast == {:node, [], []} + assert match?({:node, _, []}, ast) end test "*self* evaluates to current process" do @@ -1131,12 +1131,12 @@ defmodule CljElixir.TransformerTest do describe "symbols" do test "plain symbol becomes variable" do ast = transform("x") - assert ast == {:x, [], nil} + assert match?({:x, _, nil}, ast) end test "symbol with hyphens becomes munged variable" do ast = transform("my-var") - assert ast == {:my_var, [], nil} + assert match?({:my_var, _, nil}, ast) end test "true symbol becomes true literal" do