mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-01-29 02:09:30 +08:00
Extend Evaluator with response formatter (#23)
This commit is contained in:
parent
c05ed1c29c
commit
b487fe6104
12 changed files with 203 additions and 97 deletions
|
@ -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)
|
||||
|
|
19
lib/live_book/evaluator/formatter.ex
Normal file
19
lib/live_book/evaluator/formatter.ex
Normal 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
|
10
lib/live_book/evaluator/identity_formatter.ex
Normal file
10
lib/live_book/evaluator/identity_formatter.ex
Normal 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
|
125
lib/live_book/evaluator/string_formatter.ex
Normal file
125
lib/live_book/evaluator/string_formatter.ex
Normal 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 = [
|
||||
{?<, "<"},
|
||||
{?>, ">"},
|
||||
{?&, "&"},
|
||||
{?", """},
|
||||
{?', "'"}
|
||||
]
|
||||
|
||||
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
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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"""
|
||||
|
|
19
test/live_book/evaluator/string_formatter_test.exs
Normal file
19
test/live_book/evaluator/string_formatter_test.exs
Normal 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">"1 < 2"</span>} ==
|
||||
StringFormatter.inspect_as_html("1 < 2")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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">"1 < 2"</span>}} ==
|
||||
Helpers.inspect_as_html("1 < 2")
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue