Support ANSI escape codes (#55)

* Implement ANSI to HTML converter

* Enable ANSI escape codes by default in the standalone runtime
This commit is contained in:
Jonatan Kłosko 2021-02-22 22:08:02 +01:00 committed by GitHub
parent 48c7f9e707
commit 780ca84500
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 220 additions and 171 deletions

70
assets/css/ansi.css Normal file
View file

@ -0,0 +1,70 @@
/*
Classes for HTML-ized ANSI string.
Many colors are taken from the One Dark theme
to be consistent with the editor.
*/
.ansi.black {
@apply text-black;
}
.ansi.red {
color: #e06c75;
}
.ansi.green {
color: #98c379;
}
.ansi.yellow {
color: #e5c07b;
}
.ansi.blue {
color: #61afef;
}
.ansi.magenta {
color: #c678dd;
}
.ansi.cyan {
color: #56b6c2;
}
.ansi.white {
@apply text-white;
}
.ansi.light-black {
color: #5c6370;
}
.ansi.light-red {
@apply text-red-400;
}
.ansi.light-green {
@apply text-green-400;
}
.ansi.light-yellow {
@apply text-yellow-300;
}
.ansi.light-blue {
@apply text-blue-300;
}
.ansi.light-magenta {
@apply text-pink-400;
}
.ansi.light-cyan {
color: #6be3f2;
}
.ansi.light-white {
@apply text-white;
}

View file

@ -9,4 +9,4 @@
@import "./utilities.css";
@import "./live_view.css";
@import "./markdown.css";
@import "./elixir_inspect.css";
@import "./ansi.css";

View file

@ -1,41 +0,0 @@
/* Elixir HTML-ized inspect result */
.elixir-inspect .atom {
color: #61afef;
}
.elixir-inspect .binary {
color: #5c6370;
}
.elixir-inspect .boolean {
color: #c678dd;
}
.elixir-inspect .list {
color: #5c6370;
}
.elixir-inspect .map {
color: #5c6370;
}
.elixir-inspect .nil {
color: #c678dd;
}
.elixir-inspect .number {
color: #d19a66;
}
.elixir-inspect .regex {
color: #e06c75;
}
.elixir-inspect .string {
color: #98c379;
}
.elixir-inspect .tuple {
color: #5c6370;
}

View file

@ -7,8 +7,8 @@ defmodule LiveBook.Evaluator.StringFormatter do
@impl true
def format({:ok, value}) do
inspected = inspect_as_html(value, pretty: true, width: 100)
{:inspect_html, inspected}
inspected = inspect(value, pretty: true, width: 100, syntax_colors: syntax_colors())
{:inspect, inspected}
end
def format({:error, kind, error, stacktrace}) do
@ -16,110 +16,19 @@ defmodule LiveBook.Evaluator.StringFormatter do
{: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"
defp syntax_colors() do
[
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
atom: :blue,
binary: :light_black,
boolean: :magenta,
list: :light_black,
map: :light_black,
number: :blue,
nil: :magenta,
regex: :red,
string: :green,
tuple: :light_black,
reset: :reset
]
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

@ -58,8 +58,9 @@ defmodule LiveBook.Runtime.Standalone do
eval,
# Minimize shedulers busy wait threshold,
# so that they go to sleep immediately after evaluation.
# Enable ANSI escape codes as we handle them with HTML.
"--erl",
"+sbwt none +sbwtdcpu none +sbwtdio none"
"+sbwt none +sbwtdcpu none +sbwtdio none -elixir ansi_enabled true"
]
])

View file

@ -29,4 +29,114 @@ defmodule LiveBookWeb.Helpers do
defp linux?(user_agent), do: String.match?(user_agent, ~r/Linux/)
defp mac?(user_agent), do: String.match?(user_agent, ~r/Mac OS X/)
defp windows?(user_agent), do: String.match?(user_agent, ~r/Windows/)
@doc """
Takes a string with ANSI escape codes and build a HTML safe string
with `span` tags having classes corresponding to the escape codes.
Note that currently only one escape at a time is supported.
Any HTML in the string is escaped.
"""
@spec ansi_string_to_html(String.t()) :: Phoenix.HTML.safe()
def ansi_string_to_html(string) do
[head | tail] = String.split(string, "\e[")
{:safe, head_html} = Phoenix.HTML.html_escape(head)
tail_html =
Enum.map(tail, fn string ->
{class, rest} = ansi_prefix_to_class(string)
{:safe, content} = Phoenix.HTML.html_escape(rest)
if class do
[~s{<span class="ansi #{class}">}, content, ~s{</span>}]
else
content
end
end)
Phoenix.HTML.raw([head_html, tail_html])
end
@ansi_code_with_class [
{"0m", nil},
{"1A", "cursor-up"},
{"1B", "cursor-down"},
{"1C", "cursor-right"},
{"1D", "cursor-left"},
{"1m", "bright"},
{"2J", "clear"},
{"2K", "clear-line"},
{"2m", "faint"},
{"3m", "italic"},
{"4m", "underline"},
{"5m", "blink-slow"},
{"6m", "blink-rapid"},
{"7m", "inverse"},
{"8m", "conceal"},
{"9m", "crossed-out"},
{"10m", "primary-font"},
{"11m", "font-1"},
{"12m", "font-2"},
{"13m", "font-3"},
{"14m", "font-4"},
{"15m", "font-5"},
{"16m", "font-6"},
{"17m", "font-7"},
{"18m", "font-8"},
{"19m", "font-9"},
{"22m", "normal"},
{"23m", "not-italic"},
{"24m", "no-underline"},
{"25m", "blink-off"},
{"27m", "inverse-off"},
{"30m", "black"},
{"31m", "red"},
{"32m", "green"},
{"33m", "yellow"},
{"34m", "blue"},
{"35m", "magenta"},
{"36m", "cyan"},
{"37m", "white"},
{"39m", "default-color"},
{"40m", "black-background"},
{"41m", "red-background"},
{"42m", "green-background"},
{"43m", "yellow-background"},
{"44m", "blue-background"},
{"45m", "magenta-background"},
{"46m", "cyan-background"},
{"47m", "white-background"},
{"49m", "default-background"},
{"51m", "framed"},
{"52m", "encircled"},
{"53m", "overlined"},
{"54m", "not-framed-encircled"},
{"55m", "not-overlined"},
{"90m", "light-black"},
{"91m", "light-red"},
{"92m", "light-green"},
{"93m", "light-yellow"},
{"94m", "light-blue"},
{"95m", "light-magenta"},
{"96m", "light-cyan"},
{"97m", "light-white"},
{"100m", "light-black-background"},
{"101m", "light-red-background"},
{"102m", "light-green-background"},
{"103m", "light-yellow-background"},
{"104m", "light-blue-background"},
{"105m", "light-magenta-background"},
{"106m", "light-cyan-background"},
{"107m", "light-white-background"},
{"H", "home"}
]
for {code, class} <- @ansi_code_with_class do
defp ansi_prefix_to_class(unquote(code) <> rest) do
{unquote(class), rest}
end
end
defp ansi_prefix_to_class(string), do: {nil, string}
end

View file

@ -156,18 +156,20 @@ defmodule LiveBookWeb.CellComponent do
end
defp render_output(output) when is_binary(output) do
assigns = %{output: output}
output_html = ansi_string_to_html(output)
assigns = %{output_html: output_html}
~L"""
<div class="whitespace-pre text-gray-500"><%= @output %></div>
<div class="whitespace-pre text-gray-500"><%= @output_html %></div>
"""
end
defp render_output({:inspect_html, inspected_html}) do
defp render_output({:inspect, inspected}) do
inspected_html = ansi_string_to_html(inspected)
assigns = %{inspected_html: inspected_html}
~L"""
<div class="whitespace-pre text-gray-500 elixir-inspect"><%= raw @inspected_html %></div>
<div class="whitespace-pre text-gray-500"><%= @inspected_html %></div>
"""
end

View file

@ -1,19 +0,0 @@
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

@ -0,0 +1,17 @@
defmodule LiveBookWeb.HelpersTest do
use ExUnit.Case, async: true
alias LiveBookWeb.Helpers
describe "ansi_string_to_html/1" do
test "converts ANSI escape codes to span tags" do
assert ~s{<span class="ansi blue">:cat</span>} ==
Helpers.ansi_string_to_html("\e[34m:cat\e[0m") |> Phoenix.HTML.safe_to_string()
end
test "escapes HTML in the inspect result" do
assert ~s{&lt;div&gt;} ==
Helpers.ansi_string_to_html("<div>") |> Phoenix.HTML.safe_to_string()
end
end
end