Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions popdoc/wasm/lib/eval.ex
Original file line number Diff line number Diff line change
@@ -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
105 changes: 42 additions & 63 deletions popdoc/wasm/lib/wasm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@ 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)
end

@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)
Comment on lines +19 to +22

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should run code under task supervisor instead? Docs don't recommend setting flags.

Wasm.ready(@process_name)
# TODO: needed?
:application.set_env(:elixir, :ansi_enabled, false)
{:ok, %{sessions: %{}}}
{:ok, %{sessions: %{}, iex: nil}}
end

@impl true
Expand All @@ -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}

Expand All @@ -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}
Expand All @@ -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.
Comment on lines +79 to +80

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

" and # a run that spans the navigation gets a clean "no active session" reject."

What does that mean 😭

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)

Expand All @@ -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)

Expand All @@ -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

Expand Down
99 changes: 99 additions & 0 deletions popdoc/wasm/test/eval_test.exs
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions popdoc/wasm/test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ExUnit.start()
Loading