mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-03-04 02:43:09 +08:00
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:
parent
48c7f9e707
commit
780ca84500
9 changed files with 220 additions and 171 deletions
70
assets/css/ansi.css
Normal file
70
assets/css/ansi.css
Normal 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;
|
||||
}
|
|
@ -9,4 +9,4 @@
|
|||
@import "./utilities.css";
|
||||
@import "./live_view.css";
|
||||
@import "./markdown.css";
|
||||
@import "./elixir_inspect.css";
|
||||
@import "./ansi.css";
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 = [
|
||||
{?<, "<"},
|
||||
{?>, ">"},
|
||||
{?&, "&"},
|
||||
{?", """},
|
||||
{?', "'"}
|
||||
]
|
||||
|
||||
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
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
])
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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">"1 < 2"</span>} ==
|
||||
StringFormatter.inspect_as_html("1 < 2")
|
||||
end
|
||||
end
|
||||
end
|
17
test/live_book_web/helpers_test.exs
Normal file
17
test/live_book_web/helpers_test.exs
Normal 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{<div>} ==
|
||||
Helpers.ansi_string_to_html("<div>") |> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue