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

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 [
LiveBook.Evaluator,
LiveBook.Evaluator.IOProxy,
LiveBook.Evaluator.StringFormatter,
LiveBook.Runtime.ErlDist,
LiveBook.Runtime.ErlDist.Manager,
LiveBook.Runtime.ErlDist.EvaluatorSupervisor

View file

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

View file

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

View file

@ -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, "<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 """
Renders a component inside the `LiveBook.ModalComponent` component.

View file

@ -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"""
<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
defp render_output({:error, kind, error, stacktrace}) do
formatted = Exception.format(kind, error, stacktrace)
defp render_output({:error, formatted}) do
assigns = %{formatted: formatted}
~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.evaluate_code(node(), "1 + 1", :container1, :evaluation1)
assert_receive {:evaluation_response, :evaluation1, {:ok, 2}}
assert_receive {:evaluation_response, :evaluation1, _}
Manager.stop(node())
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