From 780ca84500309422fa2484ab378baa9ca1e564f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Mon, 22 Feb 2021 22:08:02 +0100 Subject: [PATCH] Support ANSI escape codes (#55) * Implement ANSI to HTML converter * Enable ANSI escape codes by default in the standalone runtime --- assets/css/ansi.css | 70 +++++++++++ assets/css/app.css | 2 +- assets/css/elixir_inspect.css | 41 ------ lib/live_book/evaluator/string_formatter.ex | 119 +++--------------- lib/live_book/runtime/standalone.ex | 3 +- lib/live_book_web/helpers.ex | 110 ++++++++++++++++ lib/live_book_web/live/cell_component.ex | 10 +- .../evaluator/string_formatter_test.exs | 19 --- test/live_book_web/helpers_test.exs | 17 +++ 9 files changed, 220 insertions(+), 171 deletions(-) create mode 100644 assets/css/ansi.css delete mode 100644 assets/css/elixir_inspect.css delete mode 100644 test/live_book/evaluator/string_formatter_test.exs create mode 100644 test/live_book_web/helpers_test.exs diff --git a/assets/css/ansi.css b/assets/css/ansi.css new file mode 100644 index 000000000..e805ad7ab --- /dev/null +++ b/assets/css/ansi.css @@ -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; +} diff --git a/assets/css/app.css b/assets/css/app.css index 0a807a65a..19363f706 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -9,4 +9,4 @@ @import "./utilities.css"; @import "./live_view.css"; @import "./markdown.css"; -@import "./elixir_inspect.css"; +@import "./ansi.css"; diff --git a/assets/css/elixir_inspect.css b/assets/css/elixir_inspect.css deleted file mode 100644 index 188cb7e13..000000000 --- a/assets/css/elixir_inspect.css +++ /dev/null @@ -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; -} diff --git a/lib/live_book/evaluator/string_formatter.ex b/lib/live_book/evaluator/string_formatter.ex index d6d48e343..df21cfc1b 100644 --- a/lib/live_book/evaluator/string_formatter.ex +++ b/lib/live_book/evaluator/string_formatter.ex @@ -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, []) - ":test" - """ - @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 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, "") - - {key, color}, string -> - String.replace(string, color, "") - 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(<>, 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(<>, 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 diff --git a/lib/live_book/runtime/standalone.ex b/lib/live_book/runtime/standalone.ex index 5f437c083..faaa56b6a 100644 --- a/lib/live_book/runtime/standalone.ex +++ b/lib/live_book/runtime/standalone.ex @@ -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" ] ]) diff --git a/lib/live_book_web/helpers.ex b/lib/live_book_web/helpers.ex index b5bd53d97..d26243bad 100644 --- a/lib/live_book_web/helpers.ex +++ b/lib/live_book_web/helpers.ex @@ -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{}, content, ~s{}] + 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 diff --git a/lib/live_book_web/live/cell_component.ex b/lib/live_book_web/live/cell_component.ex index 737279e94..3d12817d9 100644 --- a/lib/live_book_web/live/cell_component.ex +++ b/lib/live_book_web/live/cell_component.ex @@ -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""" -
<%= @output %>
+
<%= @output_html %>
""" 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""" -
<%= raw @inspected_html %>
+
<%= @inspected_html %>
""" end diff --git a/test/live_book/evaluator/string_formatter_test.exs b/test/live_book/evaluator/string_formatter_test.exs deleted file mode 100644 index f41d31a5e..000000000 --- a/test/live_book/evaluator/string_formatter_test.exs +++ /dev/null @@ -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{[1, 2]} == - StringFormatter.inspect_as_html([1, 2]) - end - - test "escapes HTML in the inspect result" do - assert ~s{"1 < 2"} == - StringFormatter.inspect_as_html("1 < 2") - end - end -end diff --git a/test/live_book_web/helpers_test.exs b/test/live_book_web/helpers_test.exs new file mode 100644 index 000000000..d90dcbb89 --- /dev/null +++ b/test/live_book_web/helpers_test.exs @@ -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{:cat} == + 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("
") |> Phoenix.HTML.safe_to_string() + end + end +end