From ead09fe9eee151ba9d351cfbc514daecdb36b48f Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 7 Nov 2026 09:09:26 +0200 Subject: [PATCH 1/4] Keep a session's variables across evals that don't use them Evals ran with prune_binding, which keeps only the variables the code reads or writes, so after `x = 1` and `y = 2` the session no longer had `x`. IEx doesn't prune either. --- repartee/lib/repartee/backend.ex | 5 +++-- repartee/test/repartee_backend_test.exs | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/repartee/lib/repartee/backend.ex b/repartee/lib/repartee/backend.ex index 0213e76..5086c50 100644 --- a/repartee/lib/repartee/backend.ex +++ b/repartee/lib/repartee/backend.ex @@ -43,8 +43,9 @@ defmodule Repartee.Backend do case Code.string_to_quoted(code, quoted_opts) do {:ok, quoted} -> - {value, binding, env} = - Code.eval_quoted_with_env(quoted, state.binding, state.env, prune_binding: true) + # :prune_binding would keep only the variables this code reads or + # writes, and the session would lose the rest of them. + {value, binding, env} = Code.eval_quoted_with_env(quoted, state.binding, state.env) {:ok, %{value: inspect(value, state.inspect_opts)}, %{state | binding: binding, env: env}} diff --git a/repartee/test/repartee_backend_test.exs b/repartee/test/repartee_backend_test.exs index 3d5c1cc..89c1322 100644 --- a/repartee/test/repartee_backend_test.exs +++ b/repartee/test/repartee_backend_test.exs @@ -20,6 +20,13 @@ defmodule Repartee.BackendTest do assert value == "42" end + test "variables survive evals that don't use them" do + {_, state} = eval!("x = 41", init_state()) + {_, state} = eval!("y = 1", state) + {value, _} = eval!("x + y", state) + assert value == "42" + end + test "aliases persist across evals" do {_, state} = eval!("alias String, as: S", init_state()) {value, _} = eval!(~s|S.upcase("neat")|, state) From 913fede71e2ba03d635ac49857ca71506c4e7521 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 7 Nov 2026 09:10:39 +0200 Subject: [PATCH 2/4] Give every top-level Elixir expression its own value repartee evaluated the code of an eval as a single block and sent only the last value, so a client sending `x = 1` and `x + 1` together lost the first one. The expressions run one at a time now, like in IEx and in dialtone's Erlang backend, and each one but the last sends its value as it goes. A parenthesized block is still one expression, and load-file still reports just the last value, like nREPL. --- repartee/lib/repartee/backend.ex | 40 +++++++++++++++++---- repartee/test/repartee_integration_test.exs | 25 +++++++++++-- repartee/test/support/nrepl_client.exs | 4 ++- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/repartee/lib/repartee/backend.ex b/repartee/lib/repartee/backend.ex index 5086c50..8044ce8 100644 --- a/repartee/lib/repartee/backend.ex +++ b/repartee/lib/repartee/backend.ex @@ -27,7 +27,11 @@ defmodule Repartee.Backend do end @impl true - def eval(code, meta, state) do + def eval(code, meta, state), do: eval(code, meta, state, true) + + # emit_all? says whether the expressions before the last one report their + # values too. + defp eval(code, meta, state, emit_all?) do file = Map.get(meta, :file, "nrepl") line = Map.get(meta, :line, 1) column = Map.get(meta, :column, 1) @@ -43,11 +47,7 @@ defmodule Repartee.Backend do case Code.string_to_quoted(code, quoted_opts) do {:ok, quoted} -> - # :prune_binding would keep only the variables this code reads or - # writes, and the session would lose the rest of them. - {value, binding, env} = Code.eval_quoted_with_env(quoted, state.binding, state.env) - - {:ok, %{value: inspect(value, state.inspect_opts)}, %{state | binding: binding, env: env}} + eval_exprs(top_level(quoted), state, emit_all?) {:error, {location, message, token}} -> {:error, syntax_error(file, location, message, token), state} @@ -57,7 +57,8 @@ defmodule Repartee.Backend do @impl true def load_file(contents, meta, state) do file = Map.get(meta, :path, Map.get(meta, :name, "nrepl-load-file")) - eval(contents, %{file: file, line: 1}, state) + # Like nREPL, load-file reports just the last expression's value. + eval(contents, %{file: file, line: 1}, state, false) end @impl true @@ -98,6 +99,31 @@ defmodule Repartee.Backend do } end + # Several top-level expressions parse into a block without metadata. A + # parenthesized block has some, and stays a single expression. + defp top_level({:__block__, [], [_ | _] = exprs}), do: exprs + defp top_level(quoted), do: [quoted] + + # One expression at a time, like IEx, so each one gets a value. Values + # before the last go to the client right away. + defp eval_exprs([quoted], state, _emit_all?) do + {value, state} = eval_expr(quoted, state) + {:ok, %{value: inspect(value, state.inspect_opts)}, state} + end + + defp eval_exprs([quoted | rest], state, emit_all?) do + {value, state} = eval_expr(quoted, state) + if emit_all?, do: :dialtone_backend.emit_value(inspect(value, state.inspect_opts)) + eval_exprs(rest, state, emit_all?) + end + + defp eval_expr(quoted, state) do + # :prune_binding would keep only the variables this code reads or + # writes, and the session would lose the rest of them. + {value, binding, env} = Code.eval_quoted_with_env(quoted, state.binding, state.env) + {value, %{state | binding: binding, env: env}} + end + defp syntax_error(file, location, message, token) do line = Keyword.get(List.wrap(location), :line, location) diff --git a/repartee/test/repartee_integration_test.exs b/repartee/test/repartee_integration_test.exs index c10c590..3433140 100644 --- a/repartee/test/repartee_integration_test.exs +++ b/repartee/test/repartee_integration_test.exs @@ -2,7 +2,7 @@ defmodule Repartee.IntegrationTest do use ExUnit.Case import Repartee.NreplClient, - only: [value_of: 1, out_of: 1, statuses_of: 1] + only: [value_of: 1, values_of: 1, out_of: 1, statuses_of: 1] alias Repartee.NreplClient, as: Client @@ -44,18 +44,37 @@ defmodule Repartee.IntegrationTest do assert value_of(msgs) == "43" end + test "each top-level expression gets a value", %{client: client} do + {session, client} = clone(client) + {msgs, client} = eval(client, session, "x = 1\nx + 1") + assert values_of(msgs) == ["1", "2"] + {msgs, _} = eval(client, session, "(x; x + 2)") + assert values_of(msgs) == ["3"] + end + + test "load-file sends only the last value", %{client: client} do + {msgs, _} = + Client.request(client, %{ + "op" => "load-file", + "file" => "a = 2\na * 21", + "file-name" => "x.exs" + }) + + assert values_of(msgs) == ["42"] + end + test "captures stderr when asked to", %{client: client} do :ok = :dialtone.capture_stderr() on_exit(fn -> Supervisor.terminate_child(:dialtone_sup, :dialtone_stderr) end) {session, client} = clone(client) - {msgs, _} = eval(client, session, ~s|IO.write(:stderr, "oops")\n:ok|) + {msgs, _} = eval(client, session, ~s|IO.write(:stderr, "oops")|) assert Enum.map_join(msgs, &Map.get(&1, "err", "")) == "oops" assert value_of(msgs) == ":ok" end test "streams stdout", %{client: client} do {session, client} = clone(client) - {msgs, _} = eval(client, session, ~s|IO.puts("здравей")\n:ok|) + {msgs, _} = eval(client, session, ~s|IO.puts("здравей")|) assert out_of(msgs) == "здравей\n" assert value_of(msgs) == ":ok" end diff --git a/repartee/test/support/nrepl_client.exs b/repartee/test/support/nrepl_client.exs index eb3c62d..1182cab 100644 --- a/repartee/test/support/nrepl_client.exs +++ b/repartee/test/support/nrepl_client.exs @@ -53,10 +53,12 @@ defmodule Repartee.NreplClient do end def value_of(msgs) do - [value] = for %{"value" => v} <- msgs, do: v + [value] = values_of(msgs) value end + def values_of(msgs), do: for(%{"value" => v} <- msgs, do: v) + def out_of(msgs) do Enum.map_join(for(%{"out" => o} <- msgs, do: o), "", & &1) end From 5637cccbce7e1d547dbd40928c0a302449697b0a Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 7 Nov 2026 09:48:07 +0200 Subject: [PATCH 3/4] Keep what the expressions before a failing one did Each expression sends its value as it goes now, but one that raised still took the session back to its state from before the eval, so after `y = 1` and `raise "boom"` together the client had seen y's value and couldn't use it. The expressions that ran keep what they did. IEx drops it all, but IEx also shows only the last value. --- repartee/lib/repartee/backend.ex | 9 ++++++++- repartee/test/repartee_integration_test.exs | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/repartee/lib/repartee/backend.ex b/repartee/lib/repartee/backend.ex index 8044ce8..69c6830 100644 --- a/repartee/lib/repartee/backend.ex +++ b/repartee/lib/repartee/backend.ex @@ -114,7 +114,14 @@ defmodule Repartee.Backend do defp eval_exprs([quoted | rest], state, emit_all?) do {value, state} = eval_expr(quoted, state) if emit_all?, do: :dialtone_backend.emit_value(inspect(value, state.inspect_opts)) - eval_exprs(rest, state, emit_all?) + + # A later expression that fails keeps what this one did, as the + # client may already have its value. + try do + eval_exprs(rest, state, emit_all?) + catch + kind, reason -> {:error, format_exception(kind, reason, __STACKTRACE__, state), state} + end end defp eval_expr(quoted, state) do diff --git a/repartee/test/repartee_integration_test.exs b/repartee/test/repartee_integration_test.exs index 3433140..c5a549f 100644 --- a/repartee/test/repartee_integration_test.exs +++ b/repartee/test/repartee_integration_test.exs @@ -52,6 +52,15 @@ defmodule Repartee.IntegrationTest do assert values_of(msgs) == ["3"] end + test "a failing expression keeps what the ones before it did", %{client: client} do + {session, client} = clone(client) + {msgs, client} = eval(client, session, ~s|y = 1\nraise "boom"|) + assert values_of(msgs) == ["1"] + assert "eval-error" in statuses_of(msgs) + {msgs, _} = eval(client, session, "y") + assert value_of(msgs) == "1" + end + test "load-file sends only the last value", %{client: client} do {msgs, _} = Client.request(client, %{ From 9a10c6b845eb67e3ab9fe799dc7808cd1897d2b8 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 7 Nov 2026 09:48:08 +0200 Subject: [PATCH 4/4] Test output next to more than one value again The stdout and stderr tests had become single expressions so that they got a single value. They're back to an expression after the output, which also checks that values and output stay in order. --- repartee/test/repartee_integration_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repartee/test/repartee_integration_test.exs b/repartee/test/repartee_integration_test.exs index c5a549f..6568103 100644 --- a/repartee/test/repartee_integration_test.exs +++ b/repartee/test/repartee_integration_test.exs @@ -76,16 +76,16 @@ defmodule Repartee.IntegrationTest do :ok = :dialtone.capture_stderr() on_exit(fn -> Supervisor.terminate_child(:dialtone_sup, :dialtone_stderr) end) {session, client} = clone(client) - {msgs, _} = eval(client, session, ~s|IO.write(:stderr, "oops")|) + {msgs, _} = eval(client, session, ~s|IO.write(:stderr, "oops")\n:done|) assert Enum.map_join(msgs, &Map.get(&1, "err", "")) == "oops" - assert value_of(msgs) == ":ok" + assert values_of(msgs) == [":ok", ":done"] end test "streams stdout", %{client: client} do {session, client} = clone(client) - {msgs, _} = eval(client, session, ~s|IO.puts("здравей")|) + {msgs, _} = eval(client, session, ~s|IO.puts("здравей")\n:done|) assert out_of(msgs) == "здравей\n" - assert value_of(msgs) == ":ok" + assert values_of(msgs) == [":ok", ":done"] end test "stdin roundtrip via IO.gets", %{client: client} do