From b487fe6104fcdb32c079e25a92a35f277a1a31e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Thu, 11 Feb 2021 14:04:29 +0100 Subject: [PATCH] Extend Evaluator with response formatter (#23) --- lib/live_book/evaluator.ex | 27 +++- lib/live_book/evaluator/formatter.ex | 19 +++ lib/live_book/evaluator/identity_formatter.ex | 10 ++ lib/live_book/evaluator/string_formatter.ex | 125 ++++++++++++++++++ lib/live_book/runtime/erl_dist.ex | 1 + .../runtime/erl_dist/evaluator_supervisor.ex | 2 +- lib/live_book/runtime/standalone.ex | 2 +- lib/live_book_web/helpers.ex | 62 --------- lib/live_book_web/live/cell.ex | 11 +- .../evaluator/string_formatter_test.exs | 19 +++ .../runtime/erl_dist/manager_test.exs | 2 +- test/live_book_web/helpers_test.exs | 20 --- 12 files changed, 203 insertions(+), 97 deletions(-) create mode 100644 lib/live_book/evaluator/formatter.ex create mode 100644 lib/live_book/evaluator/identity_formatter.ex create mode 100644 lib/live_book/evaluator/string_formatter.ex create mode 100644 test/live_book/evaluator/string_formatter_test.exs delete mode 100644 test/live_book_web/helpers_test.exs diff --git a/lib/live_book/evaluator.ex b/lib/live_book/evaluator.ex index 5b0cc7ad0..ea404a517 100644 --- a/lib/live_book/evaluator.ex +++ b/lib/live_book/evaluator.ex @@ -41,6 +41,14 @@ defmodule LiveBook.Evaluator do ## API + @doc """ + Starts the evaluator. + + Options: + + * `formatter` - a module implementing the `LiveBook.Evaluator.Formatter` behaviour, + used for transforming evaluation response before it's sent to the client + """ def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts) end @@ -55,6 +63,7 @@ defmodule LiveBook.Evaluator do in which case the corresponding binding and environment are used during evaluation. Evaluation response is sent to the process identified by `send_to` as `{:evaluation_response, ref, response}`. + Note that response is transformed with the configured formatter (identity by default). """ @spec evaluate_code(t(), pid(), String.t(), ref(), ref()) :: :ok def evaluate_code(evaluator, send_to, code, ref, prev_ref \\ :initial) when ref != :initial do @@ -73,17 +82,20 @@ defmodule LiveBook.Evaluator do ## Callbacks @impl true - def init(_opts) do + def init(opts) do + formatter = Keyword.get(opts, :formatter, Evaluator.IdentityFormatter) + {:ok, io_proxy} = Evaluator.IOProxy.start_link() # Use the dedicated IO device as the group leader, # so that it handles all :stdio operations. Process.group_leader(self(), io_proxy) - {:ok, initial_state(io_proxy)} + {:ok, initial_state(formatter, io_proxy)} end - defp initial_state(io_proxy) do + defp initial_state(formatter, io_proxy) do %{ + formatter: formatter, io_proxy: io_proxy, contexts: %{initial: initial_context()} } @@ -106,11 +118,11 @@ defmodule LiveBook.Evaluator do new_contexts = Map.put(state.contexts, ref, result_context) new_state = %{state | contexts: new_contexts} - send(send_to, {:evaluation_response, ref, {:ok, result}}) + send_evaluation_response(send_to, ref, {:ok, result}, state.formatter) {:noreply, new_state} {:error, kind, error, stacktrace} -> - send(send_to, {:evaluation_response, ref, {:error, kind, error, stacktrace}}) + send_evaluation_response(send_to, ref, {:error, kind, error, stacktrace}, state.formatter) {:noreply, state} end end @@ -120,6 +132,11 @@ defmodule LiveBook.Evaluator do {:noreply, new_state} end + defp send_evaluation_response(send_to, ref, evaluation_response, formatter) do + response = formatter.format(evaluation_response) + send(send_to, {:evaluation_response, ref, response}) + end + defp eval(code, binding, env) do try do quoted = Code.string_to_quoted!(code) diff --git a/lib/live_book/evaluator/formatter.ex b/lib/live_book/evaluator/formatter.ex new file mode 100644 index 000000000..7c689f5fc --- /dev/null +++ b/lib/live_book/evaluator/formatter.ex @@ -0,0 +1,19 @@ +defmodule LiveBook.Evaluator.Formatter do + @moduledoc false + + # Behaviour defining how evaluation results are transformed. + # + # The evaluation response is sent to the client as a message + # and it may potentially be huge. If the client eventually + # converts the result into some smaller representation, + # we would unnecessarily send a lot of data. + # By defining a custom formatter the client can instruct + # the `Evaluator` to send already transformed data. + + alias LiveBook.Evaluator + + @doc """ + Transforms the evaluation response. + """ + @callback format(Evaluator.evaluation_response()) :: term() +end diff --git a/lib/live_book/evaluator/identity_formatter.ex b/lib/live_book/evaluator/identity_formatter.ex new file mode 100644 index 000000000..63b261ee1 --- /dev/null +++ b/lib/live_book/evaluator/identity_formatter.ex @@ -0,0 +1,10 @@ +defmodule LiveBook.Evaluator.IdentityFormatter do + @moduledoc false + + # The default formatter leaving the response unchanged. + + @behaviour LiveBook.Evaluator.Formatter + + @impl true + def format(evaluation_response), do: evaluation_response +end diff --git a/lib/live_book/evaluator/string_formatter.ex b/lib/live_book/evaluator/string_formatter.ex new file mode 100644 index 000000000..d6d48e343 --- /dev/null +++ b/lib/live_book/evaluator/string_formatter.ex @@ -0,0 +1,125 @@ +defmodule LiveBook.Evaluator.StringFormatter do + @moduledoc false + + # The formatter used by LiveBook for rendering the results. + + @behaviour LiveBook.Evaluator.Formatter + + @impl true + def format({:ok, value}) do + inspected = inspect_as_html(value, pretty: true, width: 100) + {:inspect_html, inspected} + end + + def format({:error, kind, error, stacktrace}) do + formatted = Exception.format(kind, error, stacktrace) + {:error, formatted} + end + + @doc """ + Wraps `inspect/2` to include HTML tags in the final string for syntax highlighting. + + Any options given as the second argument are passed directly to `inspect/2`. + + ## Examples + + iex> StringFormatter.inspect_as_html(:test, []) + ":test" + """ + @spec inspect_as_html(Inspect.t(), keyword()) :: String.t() + def inspect_as_html(term, opts \\ []) do + # Inspect coloring primary tragets terminals, + # so the colors are usually ANSI escape codes + # and their effect is reverted by a special reset escape code. + # + # In our case we need HTML tags for syntax highlighting, + # so as the colors we use sequences like \xfeatom\xfe + # then we HTML-escape the string and finally replace + # these special sequences with actual tags. + # + # Note that the surrounding \xfe byte is invalid in a UTF-8 sequence, + # so we can be certain it won't appear in the normal `inspect` result. + + term + |> inspect(Keyword.merge(opts, syntax_colors: inspect_html_colors())) + |> html_escape() + |> replace_colors_with_tags() + end + + defp inspect_html_colors() do + delim = "\xfe" + + [ + atom: delim <> "atom" <> delim, + binary: delim <> "binary" <> delim, + boolean: delim <> "boolean" <> delim, + list: delim <> "list" <> delim, + map: delim <> "map" <> delim, + number: delim <> "number" <> delim, + nil: delim <> "nil" <> delim, + regex: delim <> "regex" <> delim, + string: delim <> "string" <> delim, + tuple: delim <> "tuple" <> delim, + reset: delim <> "reset" <> delim + ] + end + + defp replace_colors_with_tags(string) do + colors = inspect_html_colors() + + Enum.reduce(colors, string, fn + {:reset, color}, string -> + String.replace(string, color, "") + + {key, color}, string -> + String.replace(string, color, "") + end) + end + + # Escapes the given HTML to string. + # Taken from https://github.com/elixir-plug/plug/blob/692655393a090fbae544f5cd10255d4d600e7bb0/lib/plug/html.ex#L37 + defp html_escape(data) when is_binary(data) do + IO.iodata_to_binary(to_iodata(data, 0, data, [])) + end + + escapes = [ + {?<, "<"}, + {?>, ">"}, + {?&, "&"}, + {?", """}, + {?', "'"} + ] + + for {match, insert} <- escapes do + defp to_iodata(<>, skip, original, acc) do + to_iodata(rest, skip + 1, original, [acc | unquote(insert)]) + end + end + + defp to_iodata(<<_char, rest::bits>>, skip, original, acc) do + to_iodata(rest, skip, original, acc, 1) + end + + defp to_iodata(<<>>, _skip, _original, acc) do + acc + end + + for {match, insert} <- escapes do + defp to_iodata(<>, skip, original, acc, len) do + part = binary_part(original, skip, len) + to_iodata(rest, skip + len + 1, original, [acc, part | unquote(insert)]) + end + end + + defp to_iodata(<<_char, rest::bits>>, skip, original, acc, len) do + to_iodata(rest, skip, original, acc, len + 1) + end + + defp to_iodata(<<>>, 0, original, _acc, _len) do + original + end + + defp to_iodata(<<>>, skip, original, acc, len) do + [acc | binary_part(original, skip, len)] + end +end diff --git a/lib/live_book/runtime/erl_dist.ex b/lib/live_book/runtime/erl_dist.ex index b8d48a7e7..7bce3c1b9 100644 --- a/lib/live_book/runtime/erl_dist.ex +++ b/lib/live_book/runtime/erl_dist.ex @@ -20,6 +20,7 @@ defmodule LiveBook.Runtime.ErlDist do @required_modules [ LiveBook.Evaluator, LiveBook.Evaluator.IOProxy, + LiveBook.Evaluator.StringFormatter, LiveBook.Runtime.ErlDist, LiveBook.Runtime.ErlDist.Manager, LiveBook.Runtime.ErlDist.EvaluatorSupervisor diff --git a/lib/live_book/runtime/erl_dist/evaluator_supervisor.ex b/lib/live_book/runtime/erl_dist/evaluator_supervisor.ex index 11eb4d144..1d15a9ad5 100644 --- a/lib/live_book/runtime/erl_dist/evaluator_supervisor.ex +++ b/lib/live_book/runtime/erl_dist/evaluator_supervisor.ex @@ -24,7 +24,7 @@ defmodule LiveBook.Runtime.ErlDist.EvaluatorSupervisor do """ @spec start_evaluator() :: {:ok, Evaluator.t()} | {:error, any()} def start_evaluator() do - case DynamicSupervisor.start_child(@name, Evaluator) do + case DynamicSupervisor.start_child(@name, {Evaluator, [formatter: Evaluator.StringFormatter]}) do {:ok, pid} -> {:ok, pid} {:ok, pid, _} -> {:ok, pid} :ignore -> {:error, :ignore} diff --git a/lib/live_book/runtime/standalone.ex b/lib/live_book/runtime/standalone.ex index 07ee44dda..5f437c083 100644 --- a/lib/live_book/runtime/standalone.ex +++ b/lib/live_book/runtime/standalone.ex @@ -52,7 +52,7 @@ defmodule LiveBook.Runtime.Standalone do # unexpected messages if the process produces some output. :nouse_stdio, args: [ - if(LiveBook.Config.shortnames?, do: "--sname", else: "--name"), + if(LiveBook.Config.shortnames?(), do: "--sname", else: "--name"), to_string(node), "--eval", eval, diff --git a/lib/live_book_web/helpers.ex b/lib/live_book_web/helpers.ex index 4e2b0a887..5fbd6ab6a 100644 --- a/lib/live_book_web/helpers.ex +++ b/lib/live_book_web/helpers.ex @@ -1,68 +1,6 @@ defmodule LiveBookWeb.Helpers do import Phoenix.LiveView.Helpers - @doc """ - Wraps `inspect/2` to include HTML tags in the final string for syntax highlighting. - - Any options given as the second argument are passed directly to `inspect/2`. - - ## Examples - - iex(2)> LiveBookWeb.Helpers.inspect_as_html(:test, []) - {:safe, ":test"} - """ - @spec inspect_as_html(Inspect.t(), keyword()) :: Phoenix.HTML.safe() - def inspect_as_html(term, opts \\ []) do - # Inspect coloring primary tragets terminals, - # so the colors are usually ANSI escape codes - # and their effect is reverted by a special reset escape code. - # - # In our case we need HTML tags for syntax highlighting, - # so as the colors we use sequences like \xfeatom\xfe - # then we HTML-escape the string and finally replace - # these special sequences with actual tags. - # - # Note that the surrounding \xfe byte is invalid in a UTF-8 sequence, - # so we can be certain it won't appear in the normal `inspect` result. - - term - |> inspect(Keyword.merge(opts, syntax_colors: inspect_html_colors())) - |> Phoenix.HTML.html_escape() - |> Phoenix.HTML.safe_to_string() - |> replace_colors_with_tags() - |> Phoenix.HTML.raw() - end - - defp inspect_html_colors() do - delim = "\xfe" - - [ - atom: delim <> "atom" <> delim, - binary: delim <> "binary" <> delim, - boolean: delim <> "boolean" <> delim, - list: delim <> "list" <> delim, - map: delim <> "map" <> delim, - number: delim <> "number" <> delim, - nil: delim <> "nil" <> delim, - regex: delim <> "regex" <> delim, - string: delim <> "string" <> delim, - tuple: delim <> "tuple" <> delim, - reset: delim <> "reset" <> delim - ] - end - - defp replace_colors_with_tags(string) do - colors = inspect_html_colors() - - Enum.reduce(colors, string, fn - {:reset, color}, string -> - String.replace(string, color, "") - - {key, color}, string -> - String.replace(string, color, "") - end) - end - @doc """ Renders a component inside the `LiveBook.ModalComponent` component. diff --git a/lib/live_book_web/live/cell.ex b/lib/live_book_web/live/cell.ex index 1f3234bcb..a9d3ec409 100644 --- a/lib/live_book_web/live/cell.ex +++ b/lib/live_book_web/live/cell.ex @@ -151,18 +151,15 @@ defmodule LiveBookWeb.Cell do """ end - defp render_output({:ok, value}) do - inspected = inspect_as_html(value, pretty: true, width: 100) - - assigns = %{inspected: inspected} + defp render_output({:inspect_html, inspected_html}) do + assigns = %{inspected_html: inspected_html} ~L""" -
<%= @inspected %>
+
<%= raw @inspected_html %>
""" end - defp render_output({:error, kind, error, stacktrace}) do - formatted = Exception.format(kind, error, stacktrace) + defp render_output({:error, formatted}) do assigns = %{formatted: formatted} ~L""" diff --git a/test/live_book/evaluator/string_formatter_test.exs b/test/live_book/evaluator/string_formatter_test.exs new file mode 100644 index 000000000..f41d31a5e --- /dev/null +++ b/test/live_book/evaluator/string_formatter_test.exs @@ -0,0 +1,19 @@ +defmodule LiveBook.Evaluator.StringFormatterTest do + use ExUnit.Case, async: true + + alias LiveBook.Evaluator.StringFormatter + + doctest StringFormatter + + describe "inspect_as_html/2" do + test "uses span tags for term highlighting" do + assert ~s{[1, 2]} == + StringFormatter.inspect_as_html([1, 2]) + end + + test "escapes HTML in the inspect result" do + assert ~s{"1 < 2"} == + StringFormatter.inspect_as_html("1 < 2") + end + end +end diff --git a/test/live_book/runtime/erl_dist/manager_test.exs b/test/live_book/runtime/erl_dist/manager_test.exs index e730d18ca..649232476 100644 --- a/test/live_book/runtime/erl_dist/manager_test.exs +++ b/test/live_book/runtime/erl_dist/manager_test.exs @@ -34,7 +34,7 @@ defmodule LiveBook.Runtime.ErlDist.ManagerTest do Manager.set_owner(node(), self()) Manager.evaluate_code(node(), "1 + 1", :container1, :evaluation1) - assert_receive {:evaluation_response, :evaluation1, {:ok, 2}} + assert_receive {:evaluation_response, :evaluation1, _} Manager.stop(node()) end diff --git a/test/live_book_web/helpers_test.exs b/test/live_book_web/helpers_test.exs deleted file mode 100644 index 24161c8eb..000000000 --- a/test/live_book_web/helpers_test.exs +++ /dev/null @@ -1,20 +0,0 @@ -defmodule LiveBookWeb.HelpersTest do - use ExUnit.Case, async: true - - alias LiveBookWeb.Helpers - - doctest Helpers - - describe "inspect_as_html/2" do - test "uses span tags for term highlighting" do - assert {:safe, - ~s{[1, 2]}} == - Helpers.inspect_as_html([1, 2]) - end - - test "escapes HTML in the inspect result" do - assert {:safe, ~s{"1 < 2"}} == - Helpers.inspect_as_html("1 < 2") - end - end -end