Extend Evaluator with response formatter (#23)

This commit is contained in:
Jonatan Kłosko 2021-02-11 14:04:29 +01:00 committed by GitHub
parent c05ed1c29c
commit b487fe6104
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 203 additions and 97 deletions

View file

@ -41,6 +41,14 @@ defmodule LiveBook.Evaluator do
## API ## 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 def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts) GenServer.start_link(__MODULE__, opts)
end end
@ -55,6 +63,7 @@ defmodule LiveBook.Evaluator do
in which case the corresponding binding and environment are used during evaluation. 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}`. 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 @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 def evaluate_code(evaluator, send_to, code, ref, prev_ref \\ :initial) when ref != :initial do
@ -73,17 +82,20 @@ defmodule LiveBook.Evaluator do
## Callbacks ## Callbacks
@impl true @impl true
def init(_opts) do def init(opts) do
formatter = Keyword.get(opts, :formatter, Evaluator.IdentityFormatter)
{:ok, io_proxy} = Evaluator.IOProxy.start_link() {:ok, io_proxy} = Evaluator.IOProxy.start_link()
# Use the dedicated IO device as the group leader, # Use the dedicated IO device as the group leader,
# so that it handles all :stdio operations. # so that it handles all :stdio operations.
Process.group_leader(self(), io_proxy) Process.group_leader(self(), io_proxy)
{:ok, initial_state(io_proxy)} {:ok, initial_state(formatter, io_proxy)}
end end
defp initial_state(io_proxy) do defp initial_state(formatter, io_proxy) do
%{ %{
formatter: formatter,
io_proxy: io_proxy, io_proxy: io_proxy,
contexts: %{initial: initial_context()} contexts: %{initial: initial_context()}
} }
@ -106,11 +118,11 @@ defmodule LiveBook.Evaluator do
new_contexts = Map.put(state.contexts, ref, result_context) new_contexts = Map.put(state.contexts, ref, result_context)
new_state = %{state | contexts: new_contexts} 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} {:noreply, new_state}
{:error, kind, error, stacktrace} -> {: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} {:noreply, state}
end end
end end
@ -120,6 +132,11 @@ defmodule LiveBook.Evaluator do
{:noreply, new_state} {:noreply, new_state}
end 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 defp eval(code, binding, env) do
try do try do
quoted = Code.string_to_quoted!(code) quoted = Code.string_to_quoted!(code)

View file

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

View file

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

View file

@ -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, [])
"<span class=\\"atom\\">:test</span>"
"""
@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 <span> 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, "</span>")
{key, color}, string ->
String.replace(string, color, "<span class=\"#{Atom.to_string(key)}\">")
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 = [
{?<, "&lt;"},
{?>, "&gt;"},
{?&, "&amp;"},
{?", "&quot;"},
{?', "&#39;"}
]
for {match, insert} <- escapes do
defp to_iodata(<<unquote(match), rest::bits>>, 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(<<unquote(match), rest::bits>>, 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

View file

@ -20,6 +20,7 @@ defmodule LiveBook.Runtime.ErlDist do
@required_modules [ @required_modules [
LiveBook.Evaluator, LiveBook.Evaluator,
LiveBook.Evaluator.IOProxy, LiveBook.Evaluator.IOProxy,
LiveBook.Evaluator.StringFormatter,
LiveBook.Runtime.ErlDist, LiveBook.Runtime.ErlDist,
LiveBook.Runtime.ErlDist.Manager, LiveBook.Runtime.ErlDist.Manager,
LiveBook.Runtime.ErlDist.EvaluatorSupervisor LiveBook.Runtime.ErlDist.EvaluatorSupervisor

View file

@ -24,7 +24,7 @@ defmodule LiveBook.Runtime.ErlDist.EvaluatorSupervisor do
""" """
@spec start_evaluator() :: {:ok, Evaluator.t()} | {:error, any()} @spec start_evaluator() :: {:ok, Evaluator.t()} | {:error, any()}
def start_evaluator() do 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}
{:ok, pid, _} -> {:ok, pid} {:ok, pid, _} -> {:ok, pid}
:ignore -> {:error, :ignore} :ignore -> {:error, :ignore}

View file

@ -52,7 +52,7 @@ defmodule LiveBook.Runtime.Standalone do
# unexpected messages if the process produces some output. # unexpected messages if the process produces some output.
:nouse_stdio, :nouse_stdio,
args: [ args: [
if(LiveBook.Config.shortnames?, do: "--sname", else: "--name"), if(LiveBook.Config.shortnames?(), do: "--sname", else: "--name"),
to_string(node), to_string(node),
"--eval", "--eval",
eval, eval,

View file

@ -1,68 +1,6 @@
defmodule LiveBookWeb.Helpers do defmodule LiveBookWeb.Helpers do
import Phoenix.LiveView.Helpers 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, "<span class=\\"atom\\">:test</span>"}
"""
@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 <span> 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, "</span>")
{key, color}, string ->
String.replace(string, color, "<span class=\"#{Atom.to_string(key)}\">")
end)
end
@doc """ @doc """
Renders a component inside the `LiveBook.ModalComponent` component. Renders a component inside the `LiveBook.ModalComponent` component.

View file

@ -151,18 +151,15 @@ defmodule LiveBookWeb.Cell do
""" """
end end
defp render_output({:ok, value}) do defp render_output({:inspect_html, inspected_html}) do
inspected = inspect_as_html(value, pretty: true, width: 100) assigns = %{inspected_html: inspected_html}
assigns = %{inspected: inspected}
~L""" ~L"""
<div class="whitespace-pre text-gray-500 elixir-inspect"><%= @inspected %></div> <div class="whitespace-pre text-gray-500 elixir-inspect"><%= raw @inspected_html %></div>
""" """
end end
defp render_output({:error, kind, error, stacktrace}) do defp render_output({:error, formatted}) do
formatted = Exception.format(kind, error, stacktrace)
assigns = %{formatted: formatted} assigns = %{formatted: formatted}
~L""" ~L"""

View file

@ -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{<span class="list">[</span><span class="number">1</span><span class="list">,</span> <span class="number">2</span><span class="list">]</span>} ==
StringFormatter.inspect_as_html([1, 2])
end
test "escapes HTML in the inspect result" do
assert ~s{<span class="string">&quot;1 &lt; 2&quot;</span>} ==
StringFormatter.inspect_as_html("1 < 2")
end
end
end

View file

@ -34,7 +34,7 @@ defmodule LiveBook.Runtime.ErlDist.ManagerTest do
Manager.set_owner(node(), self()) Manager.set_owner(node(), self())
Manager.evaluate_code(node(), "1 + 1", :container1, :evaluation1) Manager.evaluate_code(node(), "1 + 1", :container1, :evaluation1)
assert_receive {:evaluation_response, :evaluation1, {:ok, 2}} assert_receive {:evaluation_response, :evaluation1, _}
Manager.stop(node()) Manager.stop(node())
end end

View file

@ -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{<span class="list">[</span><span class="number">1</span><span class="list">,</span> <span class="number">2</span><span class="list">]</span>}} ==
Helpers.inspect_as_html([1, 2])
end
test "escapes HTML in the inspect result" do
assert {:safe, ~s{<span class="string">&quot;1 &lt; 2&quot;</span>}} ==
Helpers.inspect_as_html("1 < 2")
end
end
end