mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-06 21:14:26 +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
|
## 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)
|
||||||
|
|
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 [
|
@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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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"""
|
||||||
|
|
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.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
|
||||||
|
|
|
@ -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…
Add table
Reference in a new issue