diff --git a/popdoc/wasm/lib/eval.ex b/popdoc/wasm/lib/eval.ex new file mode 100644 index 00000000..89a1aa02 --- /dev/null +++ b/popdoc/wasm/lib/eval.ex @@ -0,0 +1,95 @@ +defmodule PopdocWasm.Eval do + @moduledoc """ + Evaluation helpers shared by eval blocks and the IEx terminal. Stateless: + callers own their binding + env (`PopdocWasm` keeps them in its GenServer + state) and thread them through each call. + """ + + @doc """ + Evaluates the string `code` (possibly multi-line) IEx-style. + + Returns: + * `:incomplete` - input ends mid-expression (missing `end`, unclosed + delimiter); nothing was evaluated. Same rule IEx uses. + * `{:ok, inspected, binding, env}` + * `{:error, error_map}` - the caller keeps its binding and env. Parse + errors carry an empty stacktrace. + """ + def eval_string(code, binding, env, inspect_opts \\ []) do + with {:ok, quoted} <- parse_input(code) do + eval_quoted(quoted, binding, env, inspect_opts) + end + end + + def eval_quoted(quoted, binding, env, inspect_opts \\ []) do + {value, new_binding, new_env} = Code.eval_quoted_with_env(quoted, binding, env) + {:ok, inspect(value, [charlists: :as_lists] ++ inspect_opts), new_binding, new_env} + rescue + err -> + {:error, exception_to_error_map(err, format_user_stacktrace(__STACKTRACE__))} + catch + kind, reason -> + {:error, + %{ + kind: kind, + type: nil, + message: inspect(reason), + stacktrace: format_user_stacktrace(__STACKTRACE__) + }} + end + + def fresh_env(file) do + %Macro.Env{ + __ENV__ + | file: file, + line: 1, + module: nil, + function: nil + } + end + + defp parse_input(code) do + {:ok, Code.string_to_quoted!(ensure_trailing_newline(code))} + rescue + _ in TokenMissingError -> :incomplete + err -> {:error, exception_to_error_map(err, "")} + end + + defp ensure_trailing_newline(code) do + if String.ends_with?(code, "\n"), do: code, else: code <> "\n" + end + + defp exception_to_error_map(err, stacktrace) do + %{ + kind: :error, + type: inspect(err.__struct__), + message: Exception.message(err), + stacktrace: stacktrace + } + end + + defp format_user_stacktrace(stacktrace) do + frames = + stacktrace + |> Enum.take_while(fn + {:elixir, :eval_external_handler, _, _} -> false + _ -> true + end) + |> Enum.reject(fn + {:erlang, :apply, _, _} -> true + _ -> false + end) + + if length(frames) >= 2 do + frames + |> Exception.format_stacktrace() + |> String.split("\n") + |> Enum.map_join("\n", fn + " " <> rest -> rest + line -> line + end) + else + "" + end + end +end diff --git a/popdoc/wasm/lib/wasm.ex b/popdoc/wasm/lib/wasm.ex index fb6d789b..bb79fb29 100644 --- a/popdoc/wasm/lib/wasm.ex +++ b/popdoc/wasm/lib/wasm.ex @@ -3,10 +3,12 @@ defmodule PopdocWasm do import Popcorn.Wasm, only: [is_wasm_message: 1] alias Popcorn.Wasm + alias PopdocWasm.Eval @process_name :main @ellipsis_sentinel :__popdoc_ellipsis__ @snippet_max_len 40 + @syntax_colors IO.ANSI.syntax_colors() def start_link(args) do GenServer.start_link(__MODULE__, args, name: @process_name) @@ -14,10 +16,12 @@ defmodule PopdocWasm do @impl true def init(_args) do + # Evaluated user code runs in this process; a crashing linked process + # (spawn_link, Task.async) must deliver an exit message instead of + # killing the session with all its bindings. + Process.flag(:trap_exit, true) Wasm.ready(@process_name) - # TODO: needed? - :application.set_env(:elixir, :ansi_enabled, false) - {:ok, %{sessions: %{}}} + {:ok, %{sessions: %{}, iex: nil}} end @impl true @@ -44,7 +48,7 @@ defmodule PopdocWasm do } end) - session = %{exprs: exprs, binding: [], env: fresh_env()} + session = %{exprs: exprs, binding: [], env: Eval.fresh_env("playground")} new_state = put_in(state.sessions[block_id], session) {:resolve, %{expressions: expressions}, new_state} @@ -56,7 +60,7 @@ defmodule PopdocWasm do defp handle_wasm({:wasm_call, ["eval_one", block_id, index]}, state) when index >= 0 do with {:ok, session} <- Map.fetch(state.sessions, block_id), {_source, quoted} <- Enum.at(session.exprs, index) do - case eval_quoted(quoted, session.binding, session.env) do + case Eval.eval_quoted(quoted, session.binding, session.env) do {:ok, result, new_binding, new_env} -> bindings = diff_binding(session.binding, new_binding) updated = %{session | binding: new_binding, env: new_env} @@ -72,12 +76,45 @@ defmodule PopdocWasm do end end + # Sent on SPA navigation: eval-block sessions never outlive their page, and + # a run that spans the navigation gets a clean "no active session" reject. + defp handle_wasm({:wasm_call, ["clear_sessions"]}, state) do + {:resolve, %{}, %{state | sessions: %{}}} + end + + # Idempotent: re-calls (launcher, markdown clicks) must not wipe bindings. + defp handle_wasm({:wasm_call, ["start_iex"]}, state) do + {:resolve, %{}, ensure_iex(state)} + end + + defp handle_wasm({:wasm_call, ["iex_eval", code]}, state) when is_binary(code) do + state = ensure_iex(state) + + # Terminal-only path, so results carry IEx-style ANSI syntax colors; + # eval blocks keep plain inspect via eval_one. + case Eval.eval_string(code, state.iex.binding, state.iex.env, syntax_colors: @syntax_colors) do + :incomplete -> + {:resolve, %{status: "incomplete"}, state} + + {:ok, result, binding, env} -> + {:resolve, %{status: "ok", result: result}, %{state | iex: %{binding: binding, env: env}}} + + {:error, error_map} -> + {:resolve, %{status: "error", error: error_map}, state} + end + end + defp handle_wasm({:wasm_call, message}, state) do {:reject, "unknown wasm call: #{inspect(message)}", state} end defp handle_wasm({:wasm_cast, _message}, state), do: state + defp ensure_iex(%{iex: nil} = state), + do: %{state | iex: %{binding: [], env: Eval.fresh_env("iex")}} + + defp ensure_iex(state), do: state + defp diff_binding(old, new) do old_map = Map.new(old) @@ -86,16 +123,6 @@ defmodule PopdocWasm do end end - defp fresh_env do - %Macro.Env{ - __ENV__ - | file: "playground", - line: 1, - module: nil, - function: nil - } - end - defp parse(code) do quoted = Code.string_to_quoted!(code) @@ -110,54 +137,6 @@ defmodule PopdocWasm do error -> {:error, Exception.format(:error, error)} end - defp eval_quoted(quoted, binding, env) do - {value, new_binding, new_env} = Code.eval_quoted_with_env(quoted, binding, env) - {:ok, inspect(value, charlists: :as_lists), new_binding, new_env} - rescue - err -> - {:error, - %{ - kind: :error, - type: inspect(err.__struct__), - message: Exception.message(err), - stacktrace: format_user_stacktrace(__STACKTRACE__) - }} - catch - kind, reason -> - {:error, - %{ - kind: kind, - type: nil, - message: inspect(reason), - stacktrace: format_user_stacktrace(__STACKTRACE__) - }} - end - - defp format_user_stacktrace(stacktrace) do - frames = - stacktrace - |> Enum.take_while(fn - {:elixir, :eval_external_handler, _, _} -> false - _ -> true - end) - |> Enum.reject(fn - {:erlang, :apply, _, _} -> true - _ -> false - end) - - if length(frames) >= 2 do - frames - |> Exception.format_stacktrace() - |> String.split("\n") - |> Enum.map_join("\n", fn - " " <> rest -> rest - line -> line - end) - else - "" - end - end - defp start_line({_, meta, _}) when is_list(meta), do: Keyword.get(meta, :line, 1) defp start_line(_), do: 1 diff --git a/popdoc/wasm/test/eval_test.exs b/popdoc/wasm/test/eval_test.exs new file mode 100644 index 00000000..273ba5f8 --- /dev/null +++ b/popdoc/wasm/test/eval_test.exs @@ -0,0 +1,99 @@ +defmodule PopdocWasm.EvalTest do + use ExUnit.Case, async: true + + alias PopdocWasm.Eval + + defp eval(code, binding \\ [], opts \\ []) do + Eval.eval_string(code, binding, Eval.fresh_env("test"), opts) + end + + describe "incomplete input" do + test "dangling operator" do + assert :incomplete = eval("1 +") + end + + test "missing end" do + assert :incomplete = eval("defmodule Foo do") + end + + test "unclosed list" do + assert :incomplete = eval("[1, 2") + end + + test "unclosed string" do + assert :incomplete = eval(~s("abc)) + end + + test "heredoc opener without trailing newline" do + assert :incomplete = eval(~s(@doc """)) + assert :incomplete = eval(~s(x = """)) + end + + test "completed heredoc evaluates" do + assert {:ok, result, _, _} = eval("x = \"\"\"\nhello\n\"\"\"") + assert result =~ "hello" + end + end + + describe "parse errors" do + test "syntax error maps to error with empty stacktrace" do + assert {:error, error} = eval("1 )") + assert error.type in ["SyntaxError", "MismatchedDelimiterError"] + assert error.stacktrace == "" + end + end + + describe "evaluation" do + test "simple expression" do + assert {:ok, "2", _, _} = eval("1 + 1") + end + + test "bindings persist across evals" do + {:ok, "41", binding, env} = eval("x = 41") + assert {:ok, "42", _, _} = Eval.eval_string("x + 1", binding, env) + end + + test "rebinding works" do + {:ok, _, binding, env} = eval("x = 1") + {:ok, _, binding, env} = Eval.eval_string("x = 2", binding, env) + assert {:ok, "2", _, _} = Eval.eval_string("x", binding, env) + end + + test "inspect opts colorize the result" do + assert {:ok, result, _, _} = eval(":ok", [], syntax_colors: IO.ANSI.syntax_colors()) + assert result =~ "\e[" + assert result =~ ":ok" + end + + test "multi-expression text returns the last value" do + assert {:ok, "2", _, _} = eval("a = 1\na + 1") + end + + test "continuation completes once the input closes" do + assert :incomplete = eval("if true do") + assert :incomplete = eval("if true do\n1") + assert {:ok, "1", _, _} = eval("if true do\n1\nend") + end + end + + describe "runtime errors" do + test "exception maps to error with type" do + assert {:error, error} = eval("1 / 0") + assert error.type == "ArithmeticError" + assert error.kind == :error + end + + test "bindings survive a failed eval" do + {:ok, _, binding, env} = eval("x = 5") + assert {:error, _} = Eval.eval_string("raise \"boom\"", binding, env) + assert {:ok, "5", _, _} = Eval.eval_string("x", binding, env) + end + + test "throw maps to kind :throw" do + assert {:error, error} = eval("throw :ball") + assert error.kind == :throw + assert error.type == nil + assert error.message == ":ball" + end + end +end diff --git a/popdoc/wasm/test/test_helper.exs b/popdoc/wasm/test/test_helper.exs new file mode 100644 index 00000000..869559e7 --- /dev/null +++ b/popdoc/wasm/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()