-
Notifications
You must be signed in to change notification settings - Fork 21
popdoc: add IEx session to wasm module (2/5) #696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mmietla
wants to merge
1
commit into
main
Choose a base branch
from
popdoc-wasm-iex
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| 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. | ||
|
Comment on lines
+79
to
+80
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ExUnit.start() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.