diff --git a/lib/livebook/utils/ansi.ex b/lib/livebook/utils/ansi.ex new file mode 100644 index 000000000..106ca4e58 --- /dev/null +++ b/lib/livebook/utils/ansi.ex @@ -0,0 +1,191 @@ +defmodule Livebook.Utils.ANSI.Modifier do + @moduledoc false + + defmacro defmodifier(modifier, code, terminator \\ "m") do + quote bind_quoted: [modifier: modifier, code: code, terminator: terminator] do + defp ansi_prefix_to_modifier(unquote("[#{code}#{terminator}") <> rest) do + {:ok, unquote(modifier), rest} + end + end + end +end + +defmodule Livebook.Utils.ANSI do + @moduledoc false + + import Livebook.Utils.ANSI.Modifier + + @type modifier :: + {:font_weight, :bold | :light} + | {:font_style, :italic} + | {:text_decoration, :underline | :line_through | :overline} + | {:foreground_color, color()} + | {:background_color, color()} + + @type color :: basic_color() | {:grayscale24, 0..23} | {:rgb6, 0..5, 0..5, 0..5} + + @type basic_color :: + :black + | :red + | :green + | :yellow + | :blue + | :magenta + | :cyan + | :white + | :light_black + | :light_red + | :light_green + | :light_yellow + | :light_blue + | :light_magenta + | :light_cyan + | :light_white + + @doc """ + Takes a string with ANSI escape codes and parses it + into a list of `{modifiers, string}` parts. + """ + @spec parse_ansi_string(String.t()) :: list({list(modifier()), String.t()}) + def parse_ansi_string(string) do + [head | ansi_prefixed_strings] = String.split(string, "\e") + + # Each part has the form of {modifiers, string} + {tail_parts, _} = + Enum.map_reduce(ansi_prefixed_strings, %{}, fn string, modifiers -> + {modifiers, rest} = + case ansi_prefix_to_modifier(string) do + {:ok, modifier, rest} -> + modifiers = add_modifier(modifiers, modifier) + {modifiers, rest} + + {:error, _rest} -> + {modifiers, "\e" <> string} + end + + {{Map.to_list(modifiers), rest}, modifiers} + end) + + parts = [{[], head} | tail_parts] + + parts + |> Enum.reject(fn {_modifiers, string} -> string == "" end) + |> merge_adjacent_parts([]) + end + + defp merge_adjacent_parts([], acc), do: Enum.reverse(acc) + + defp merge_adjacent_parts([{modifiers, string1}, {modifiers, string2} | parts], acc) do + merge_adjacent_parts([{modifiers, string1 <> string2} | parts], acc) + end + + defp merge_adjacent_parts([part | parts], acc) do + merge_adjacent_parts(parts, [part | acc]) + end + + # Below goes a number of `ansi_prefix_to_modifier` function definitions, + # that take a string like "[32msomething" (starting with ANSI code without the leading "\e") + # and parse the prefix into the corresponding modifier. + # The function returns either {:ok, modifier, rest} or {:error, rest} + + defmodifier(:reset, 0) + + # When the code is missing (i.e., "\e[m"), it is 0 for reset. + defmodifier(:reset, "") + + @colors [:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white] + + for {color, index} <- Enum.with_index(@colors) do + defmodifier({:foreground_color, color}, 30 + index) + defmodifier({:background_color, color}, 40 + index) + defmodifier({:foreground_color, :"light_#{color}"}, 90 + index) + defmodifier({:background_color, :"light_#{color}"}, 100 + index) + end + + defmodifier({:foreground_color, :reset}, 39) + defmodifier({:background_color, :reset}, 49) + + defmodifier({:font_weight, :bold}, 1) + defmodifier({:font_weight, :light}, 2) + defmodifier({:font_style, :italic}, 3) + defmodifier({:text_decoration, :underline}, 4) + defmodifier({:text_decoration, :line_through}, 9) + defmodifier({:font_weight, :reset}, 22) + defmodifier({:font_style, :reset}, 23) + defmodifier({:text_decoration, :reset}, 24) + defmodifier({:text_decoration, :overline}, 53) + defmodifier({:text_decoration, :reset}, 55) + + defp ansi_prefix_to_modifier("[38;5;" <> string) do + with {:ok, color, rest} <- bit8_prefix_to_color(string) do + {:ok, {:foreground_color, color}, rest} + end + end + + defp ansi_prefix_to_modifier("[48;5;" <> string) do + with {:ok, color, rest} <- bit8_prefix_to_color(string) do + {:ok, {:background_color, color}, rest} + end + end + + # "\e(B" is RFC1468's switch to ASCII character set and can be ignored. This + # can appear even when JIS character sets aren't in use. + defp ansi_prefix_to_modifier("(B" <> rest) do + {:ok, :ignored, rest} + end + + defp bit8_prefix_to_color(string) do + case Integer.parse(string) do + {n, "m" <> rest} when n in 0..255 -> + color = color_from_code(n) + {:ok, color, rest} + + _ -> + {:error, string} + end + end + + ignored_codes = [5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 25, 27, 51, 52, 54] + + for code <- ignored_codes do + defmodifier(:ignored, code) + end + + defmodifier(:ignored, 1, "A") + defmodifier(:ignored, 1, "B") + defmodifier(:ignored, 1, "C") + defmodifier(:ignored, 1, "D") + defmodifier(:ignored, 2, "J") + defmodifier(:ignored, 2, "K") + defmodifier(:ignored, "", "H") + + defp ansi_prefix_to_modifier(string), do: {:error, string} + + defp color_from_code(code) when code in 0..7 do + Enum.at(@colors, code) + end + + defp color_from_code(code) when code in 8..15 do + color = Enum.at(@colors, code - 8) + :"light_#{color}" + end + + defp color_from_code(code) when code in 16..231 do + rgb_code = code - 16 + b = rgb_code |> rem(6) + g = rgb_code |> div(6) |> rem(6) + r = rgb_code |> div(36) + + {:rgb6, r, g, b} + end + + defp color_from_code(code) when code in 232..255 do + level = code - 232 + {:grayscale24, level} + end + + defp add_modifier(modifiers, :ignored), do: modifiers + defp add_modifier(_modifiers, :reset), do: %{} + defp add_modifier(modifiers, {key, :reset}), do: Map.delete(modifiers, key) + defp add_modifier(modifiers, {key, value}), do: Map.put(modifiers, key, value) +end diff --git a/lib/livebook_web/ansi.ex b/lib/livebook_web/ansi.ex deleted file mode 100644 index 9b48f60c9..000000000 --- a/lib/livebook_web/ansi.ex +++ /dev/null @@ -1,255 +0,0 @@ -defmodule Livebook.ANSI.Modifier do - @moduledoc false - - defmacro defmodifier(modifier, code, terminator \\ "m") do - quote bind_quoted: [modifier: modifier, code: code, terminator: terminator] do - defp ansi_prefix_to_modifier(unquote("[#{code}#{terminator}") <> rest) do - {:ok, unquote(modifier), rest} - end - end - end -end - -defmodule LivebookWeb.ANSI do - @moduledoc false - - import Livebook.ANSI.Modifier - - # modifier :: - # :reset - # | {:font_weight, :bold | :light | :reset} - # | {:font_style, :italic | :reset} - # | {:text_decoration, :underline | :line_through | :overline | :reset} - # | {:foreground_color, color() | :reset} - # | {:background_color, color() | :reset} - # | :ignored - - # color :: atom() | {:grayscale24, 0..23} | {:rgb6, 0..5, 0..5, 0..5} - - @doc """ - Takes a string with ANSI escape codes and build a HTML safe string - with `span` tags having classes corresponding to the escape codes. - - Any HTML in the string is escaped. - - ## Options - - * `:renderer` - a function used to render styled HTML content. - The function receives HTML styles string and HTML-escaped content (iodata). - By default the renderer wraps the whole content in a single `` tag with the given style. - Note that the style may be an empty string for plain text. - """ - @spec ansi_string_to_html(String.t(), keyword()) :: Phoenix.HTML.safe() - def ansi_string_to_html(string, opts \\ []) do - renderer = Keyword.get(opts, :renderer, &default_renderer/2) - - [head | ansi_prefixed_strings] = String.split(string, "\e") - - {:safe, head_html} = Phoenix.HTML.html_escape(head) - head_html = renderer.("", head_html) - - # Each pair has the form of {modifiers, html_content} - {pairs, _} = - Enum.map_reduce(ansi_prefixed_strings, %{}, fn string, modifiers -> - {modifiers, rest} = - case ansi_prefix_to_modifier(string) do - {:ok, modifier, rest} -> - modifiers = add_modifier(modifiers, modifier) - {modifiers, rest} - - {:error, _rest} -> - {modifiers, "\e" <> string} - end - - {:safe, content} = Phoenix.HTML.html_escape(rest) - {{modifiers, content}, modifiers} - end) - - pairs = Enum.filter(pairs, fn {_modifiers, content} -> content not in ["", []] end) - - tail_html = pairs_to_html(pairs, renderer) - - Phoenix.HTML.raw([head_html, tail_html]) - end - - # Below goes a number of `ansi_prefix_to_modifier` function definitions, - # that take a string like "[32msomething" (starting with ANSI code without the leading "\e") - # and parse the prefix into the corresponding modifier. - # The function returns either {:ok, modifier, rest} or {:error, rest} - - defmodifier(:reset, 0) - - # When the code is missing (i.e., "\e[m"), it is 0 for reset. - defmodifier(:reset, "") - - @colors [:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white] - - for {color, index} <- Enum.with_index(@colors) do - defmodifier({:foreground_color, color}, 30 + index) - defmodifier({:background_color, color}, 40 + index) - defmodifier({:foreground_color, :"light_#{color}"}, 90 + index) - defmodifier({:background_color, :"light_#{color}"}, 100 + index) - end - - defmodifier({:foreground_color, :reset}, 39) - defmodifier({:background_color, :reset}, 49) - - defmodifier({:font_weight, :bold}, 1) - defmodifier({:font_weight, :light}, 2) - defmodifier({:font_style, :italic}, 3) - defmodifier({:text_decoration, :underline}, 4) - defmodifier({:text_decoration, :line_through}, 9) - defmodifier({:font_weight, :reset}, 22) - defmodifier({:font_style, :reset}, 23) - defmodifier({:text_decoration, :reset}, 24) - defmodifier({:text_decoration, :overline}, 53) - defmodifier({:text_decoration, :reset}, 55) - - defp ansi_prefix_to_modifier("[38;5;" <> string) do - with {:ok, color, rest} <- bit8_prefix_to_color(string) do - {:ok, {:foreground_color, color}, rest} - end - end - - defp ansi_prefix_to_modifier("[48;5;" <> string) do - with {:ok, color, rest} <- bit8_prefix_to_color(string) do - {:ok, {:background_color, color}, rest} - end - end - - # "\e(B" is RFC1468's switch to ASCII character set and can be ignored. This - # can appear even when JIS character sets aren't in use. - defp ansi_prefix_to_modifier("(B" <> rest) do - {:ok, :ignored, rest} - end - - defp bit8_prefix_to_color(string) do - case Integer.parse(string) do - {n, "m" <> rest} when n in 0..255 -> - color = color_from_code(n) - {:ok, color, rest} - - _ -> - {:error, string} - end - end - - ignored_codes = [5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 25, 27, 51, 52, 54] - - for code <- ignored_codes do - defmodifier(:ignored, code) - end - - defmodifier(:ignored, 1, "A") - defmodifier(:ignored, 1, "B") - defmodifier(:ignored, 1, "C") - defmodifier(:ignored, 1, "D") - defmodifier(:ignored, 2, "J") - defmodifier(:ignored, 2, "K") - defmodifier(:ignored, "", "H") - - defp ansi_prefix_to_modifier(string), do: {:error, string} - - defp color_from_code(code) when code in 0..7 do - Enum.at(@colors, code) - end - - defp color_from_code(code) when code in 8..15 do - color = Enum.at(@colors, code - 8) - :"light_#{color}" - end - - defp color_from_code(code) when code in 16..231 do - rgb_code = code - 16 - b = rgb_code |> rem(6) - g = rgb_code |> div(6) |> rem(6) - r = rgb_code |> div(36) - - {:rgb6, r, g, b} - end - - defp color_from_code(code) when code in 232..255 do - level = code - 232 - {:grayscale24, level} - end - - defp add_modifier(modifiers, :ignored), do: modifiers - defp add_modifier(_modifiers, :reset), do: %{} - defp add_modifier(modifiers, {key, :reset}), do: Map.delete(modifiers, key) - defp add_modifier(modifiers, {key, value}), do: Map.put(modifiers, key, value) - - # Converts a list of {modifiers, html_content} pairs - # into HTML with appropriate styling. - defp pairs_to_html(pairs, iodata \\ [], renderer) - - defp pairs_to_html([], iodata, _renderer), do: iodata - - defp pairs_to_html([{modifiers, content1}, {modifiers, content2} | pairs], iodata, renderer) do - # Merge content with the same modifiers, so we don't produce unnecessary tags - pairs_to_html([{modifiers, [content1, content2]} | pairs], iodata, renderer) - end - - defp pairs_to_html([{modifiers, content} | pairs], iodata, renderer) do - style = modifiers_to_css(modifiers) - rendered = renderer.(style, content) - - pairs_to_html(pairs, [iodata, rendered], renderer) - end - - def default_renderer("", content) do - content - end - - def default_renderer(style, content) do - [~s{}, content, ~s{}] - end - - defp modifiers_to_css(modifiers) do - modifiers - |> Enum.map(&modifier_to_css/1) - |> Enum.join() - end - - defp modifier_to_css({:font_weight, :bold}), do: "font-weight: 600;" - defp modifier_to_css({:font_weight, :light}), do: "font-weight: 200;" - - defp modifier_to_css({:font_style, :italic}), do: "font-style: italic;" - - defp modifier_to_css({:text_decoration, :underline}), do: "text-decoration: underline;" - defp modifier_to_css({:text_decoration, :line_through}), do: "text-decoration: line-through;" - defp modifier_to_css({:text_decoration, :overline}), do: "text-decoration: overline;" - - defp modifier_to_css({:foreground_color, color}), do: "color: #{color_to_css(color)};" - - defp modifier_to_css({:background_color, color}), - do: "background-color: #{color_to_css(color)};" - - defp color_to_css(:black), do: "var(--ansi-color-black)" - defp color_to_css(:light_black), do: "var(--ansi-color-light-black)" - defp color_to_css(:red), do: "var(--ansi-color-red)" - defp color_to_css(:light_red), do: "var(--ansi-color-light-red)" - defp color_to_css(:green), do: "var(--ansi-color-green)" - defp color_to_css(:light_green), do: "var(--ansi-color-light-green)" - defp color_to_css(:yellow), do: "var(--ansi-color-yellow)" - defp color_to_css(:light_yellow), do: "var(--ansi-color-light-yellow)" - defp color_to_css(:blue), do: "var(--ansi-color-blue)" - defp color_to_css(:light_blue), do: "var(--ansi-color-light-blue)" - defp color_to_css(:magenta), do: "var(--ansi-color-magenta)" - defp color_to_css(:light_magenta), do: "var(--ansi-color-light-magenta)" - defp color_to_css(:cyan), do: "var(--ansi-color-cyan)" - defp color_to_css(:light_cyan), do: "var(--ansi-color-light-cyan)" - defp color_to_css(:white), do: "var(--ansi-color-white)" - defp color_to_css(:light_white), do: "var(--ansi-color-light-white)" - - defp color_to_css({:rgb6, r, g, b}) do - r = div(255 * r, 5) - g = div(255 * g, 5) - b = div(255 * b, 5) - "rgb(#{r}, #{g}, #{b})" - end - - defp color_to_css({:grayscale24, level}) do - value = div(255 * level, 23) - "rgb(#{value}, #{value}, #{value})" - end -end diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index 92f9d3af8..7e78d5f00 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -41,35 +41,6 @@ defmodule LivebookWeb.Helpers do defp mac?(user_agent), do: String.match?(user_agent, ~r/Mac OS X/) defp windows?(user_agent), do: String.match?(user_agent, ~r/Windows/) - defdelegate ansi_string_to_html(string, opts \\ []), to: LivebookWeb.ANSI - - @doc """ - Converts a string with ANSI escape codes into HTML lines. - - This method is similar to `ansi_string_to_html/2`, - but makes sure each line is itself a valid HTML - (as opposed to just splitting HTML into lines). - """ - @spec ansi_to_html_lines(String.t()) :: list(Phoenix.HTML.safe()) - def ansi_to_html_lines(string) do - string - |> ansi_string_to_html( - # Make sure every line is styled separately, - # so that later we can safely split the whole HTML - # into valid HTML lines. - renderer: fn style, content -> - content - |> IO.iodata_to_binary() - |> String.split("\n") - |> Enum.map(&LivebookWeb.ANSI.default_renderer(style, &1)) - |> Enum.intersperse("\n") - end - ) - |> Phoenix.HTML.safe_to_string() - |> String.split("\n") - |> Enum.map(&Phoenix.HTML.raw/1) - end - @doc """ Returns path to specific process dialog within LiveDashboard. """ @@ -236,4 +207,7 @@ defmodule LivebookWeb.Helpers do """ end + + defdelegate ansi_string_to_html(string), to: LivebookWeb.Helpers.ANSI + defdelegate ansi_string_to_html_lines(string), to: LivebookWeb.Helpers.ANSI end diff --git a/lib/livebook_web/helpers/ansi.ex b/lib/livebook_web/helpers/ansi.ex new file mode 100644 index 000000000..4fb887532 --- /dev/null +++ b/lib/livebook_web/helpers/ansi.ex @@ -0,0 +1,109 @@ +defmodule LivebookWeb.Helpers.ANSI do + @moduledoc false + + @doc """ + Takes a string with ANSI escape codes and build a HTML safe string + with `span` tags having classes corresponding to the escape codes. + + Any HTML in the string is escaped. + """ + @spec ansi_string_to_html(String.t()) :: Phoenix.HTML.safe() + def ansi_string_to_html(string) do + string + |> Livebook.Utils.ANSI.parse_ansi_string() + |> parts_to_html() + end + + defp parts_to_html(parts) do + parts + |> Enum.map(fn {modifiers, string} -> + style = modifiers_to_css(modifiers) + {:safe, escaped} = Phoenix.HTML.html_escape(string) + + if style == "" or string == "" do + escaped + else + [~s{}, escaped, ~s{}] + end + end) + |> Phoenix.HTML.raw() + end + + defp modifiers_to_css(modifiers) do + modifiers + |> Enum.map(&modifier_to_css/1) + |> Enum.join() + end + + defp modifier_to_css({:font_weight, :bold}), do: "font-weight: 600;" + defp modifier_to_css({:font_weight, :light}), do: "font-weight: 200;" + + defp modifier_to_css({:font_style, :italic}), do: "font-style: italic;" + + defp modifier_to_css({:text_decoration, :underline}), do: "text-decoration: underline;" + defp modifier_to_css({:text_decoration, :line_through}), do: "text-decoration: line-through;" + defp modifier_to_css({:text_decoration, :overline}), do: "text-decoration: overline;" + + defp modifier_to_css({:foreground_color, color}), do: "color: #{color_to_css(color)};" + + defp modifier_to_css({:background_color, color}), + do: "background-color: #{color_to_css(color)};" + + defp color_to_css(:black), do: "var(--ansi-color-black)" + defp color_to_css(:light_black), do: "var(--ansi-color-light-black)" + defp color_to_css(:red), do: "var(--ansi-color-red)" + defp color_to_css(:light_red), do: "var(--ansi-color-light-red)" + defp color_to_css(:green), do: "var(--ansi-color-green)" + defp color_to_css(:light_green), do: "var(--ansi-color-light-green)" + defp color_to_css(:yellow), do: "var(--ansi-color-yellow)" + defp color_to_css(:light_yellow), do: "var(--ansi-color-light-yellow)" + defp color_to_css(:blue), do: "var(--ansi-color-blue)" + defp color_to_css(:light_blue), do: "var(--ansi-color-light-blue)" + defp color_to_css(:magenta), do: "var(--ansi-color-magenta)" + defp color_to_css(:light_magenta), do: "var(--ansi-color-light-magenta)" + defp color_to_css(:cyan), do: "var(--ansi-color-cyan)" + defp color_to_css(:light_cyan), do: "var(--ansi-color-light-cyan)" + defp color_to_css(:white), do: "var(--ansi-color-white)" + defp color_to_css(:light_white), do: "var(--ansi-color-light-white)" + + defp color_to_css({:rgb6, r, g, b}) do + r = div(255 * r, 5) + g = div(255 * g, 5) + b = div(255 * b, 5) + "rgb(#{r}, #{g}, #{b})" + end + + defp color_to_css({:grayscale24, level}) do + value = div(255 * level, 23) + "rgb(#{value}, #{value}, #{value})" + end + + @doc """ + Converts a string with ANSI escape codes into HTML lines. + + This method is similar to `ansi_string_to_html/1`, + but makes sure each line is itself a valid HTML + (as opposed to just splitting HTML into lines). + """ + @spec ansi_string_to_html_lines(String.t()) :: list(Phoenix.HTML.safe()) + def ansi_string_to_html_lines(string) do + string + |> Livebook.Utils.ANSI.parse_ansi_string() + |> split_parts_into_lines() + |> Enum.map(&parts_to_html/1) + end + + defp split_parts_into_lines(parts), do: split_parts_into_lines(parts, [[]]) + + defp split_parts_into_lines([], groups) do + groups + |> Enum.map(&Enum.reverse/1) + |> Enum.reverse() + end + + defp split_parts_into_lines([{modifiers, string} | parts], [group | groups]) do + [line | lines] = String.split(string, "\n") + new_groups = lines |> Enum.map(&[{modifiers, &1}]) |> Enum.reverse() + split_parts_into_lines(parts, new_groups ++ [[{modifiers, line} | group] | groups]) + end +end diff --git a/lib/livebook_web/live/output/text_component.ex b/lib/livebook_web/live/output/text_component.ex index 7da73329d..ec906dea2 100644 --- a/lib/livebook_web/live/output/text_component.ex +++ b/lib/livebook_web/live/output/text_component.ex @@ -12,7 +12,7 @@ defmodule LivebookWeb.Output.TextComponent do <%# Add a newline to each element, so that multiple lines can be copied properly %> + ><%= for line <- ansi_string_to_html_lines(@content) do %>
<%= [line, "\n"] %>
<% end %>
diff --git a/test/livebook/utils/ansi_test.exs b/test/livebook/utils/ansi_test.exs new file mode 100644 index 000000000..0119b83d5 --- /dev/null +++ b/test/livebook/utils/ansi_test.exs @@ -0,0 +1,68 @@ +defmodule Livebook.Utils.ANSITest do + use ExUnit.Case, async: true + + alias Livebook.Utils.ANSI + + describe "parse_ansi_string/1" do + test "converts ANSI escape codes to predefined modifiers" do + assert ANSI.parse_ansi_string("\e[34mcat\e[0m") == [{[foreground_color: :blue], "cat"}] + assert ANSI.parse_ansi_string("\e[44mcat\e[0m") == [{[background_color: :blue], "cat"}] + assert ANSI.parse_ansi_string("\e[1mcat\e[0m") == [{[font_weight: :bold], "cat"}] + assert ANSI.parse_ansi_string("\e[4mcat\e[0m") == [{[text_decoration: :underline], "cat"}] + end + + test "supports short reset sequence" do + assert ANSI.parse_ansi_string("\e[34mcat\e[m") == [{[foreground_color: :blue], "cat"}] + end + + test "supports multiple escape codes at the same time" do + assert ANSI.parse_ansi_string("\e[34m\e[41mcat\e[0m") == + [{[background_color: :red, foreground_color: :blue], "cat"}] + end + + test "overriding a particular style property keeps the others" do + assert [{[background_color: :red], "cat"}] == + ANSI.parse_ansi_string("\e[34m\e[41m\e[39mcat\e[0m") + end + + test "adjacent content with the same properties is wrapped in a single pair" do + assert ANSI.parse_ansi_string("\e[34mcool\e[0m\e[34mcats\e[0m") == + [{[foreground_color: :blue], "coolcats"}] + end + + test "modifiers have effect until reset" do + assert ANSI.parse_ansi_string("\e[34mcool\e[4mcats\e[0m") == + [ + {[foreground_color: :blue], "cool"}, + {[foreground_color: :blue, text_decoration: :underline], "cats"} + ] + end + + test "supports 8-bit rgb colors" do + assert ANSI.parse_ansi_string("\e[38;5;67mcat\e[0m") == + [{[foreground_color: {:rgb6, 1, 2, 3}], "cat"}] + end + + test "supports 8-bit grayscale range" do + assert ANSI.parse_ansi_string("\e[38;5;240mcat\e[0m") == + [{[foreground_color: {:grayscale24, 8}], "cat"}] + end + + test "supports 8-bit well known colors" do + assert ANSI.parse_ansi_string("\e[38;5;1mcat\e[0m") == + [{[foreground_color: :red], "cat"}] + end + + test "ignores valid but irrelevant escape codes" do + assert ANSI.parse_ansi_string("\e[H\e[1Acat") == [{[], "cat"}] + end + + test "returns the whole string if on ANSI code detected" do + assert ANSI.parse_ansi_string("\e[300mcat") == [{[], "\e[300mcat"}] + end + + test "ignores RFC 1468 switch to ASCII" do + assert ANSI.parse_ansi_string("\e(Bcat") == [{[], "cat"}] + end + end +end diff --git a/test/livebook_web/ansi_test.exs b/test/livebook_web/ansi_test.exs deleted file mode 100644 index 7d065b132..000000000 --- a/test/livebook_web/ansi_test.exs +++ /dev/null @@ -1,102 +0,0 @@ -defmodule LivebookWeb.ANSITest do - use ExUnit.Case, async: true - - alias LivebookWeb.ANSI - - describe "ansi_string_to_html/2" do - test "converts ANSI escape codes to span tags" do - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[34mcat\e[0m") |> Phoenix.HTML.safe_to_string() - - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[44mcat\e[0m") |> Phoenix.HTML.safe_to_string() - - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[1mcat\e[0m") |> Phoenix.HTML.safe_to_string() - - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[4mcat\e[0m") |> Phoenix.HTML.safe_to_string() - end - - test "supports short reset sequence" do - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[34mcat\e[m") |> Phoenix.HTML.safe_to_string() - end - - test "supports multiple escape codes at the same time" do - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[34m\e[41mcat\e[0m") |> Phoenix.HTML.safe_to_string() - end - - test "overriding a particular style property keeps the others" do - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[34m\e[41m\e[39mcat\e[0m") - |> Phoenix.HTML.safe_to_string() - end - - test "adjacent content with the same properties is wrapped in a single element" do - assert ~s{coolcats} == - ANSI.ansi_string_to_html("\e[34mcool\e[0m\e[34mcats\e[0m") - |> Phoenix.HTML.safe_to_string() - end - - test "modifiers have effect until reset" do - assert ~s{coolcats} == - ANSI.ansi_string_to_html("\e[34mcool\e[4mcats\e[0m") - |> Phoenix.HTML.safe_to_string() - end - - test "supports 8-bit rgb colors" do - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[38;5;67mcat\e[0m") |> Phoenix.HTML.safe_to_string() - end - - test "supports 8-bit grayscale range" do - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[38;5;240mcat\e[0m") |> Phoenix.HTML.safe_to_string() - end - - test "supports 8-bit well known colors" do - assert ~s{cat} == - ANSI.ansi_string_to_html("\e[38;5;1mcat\e[0m") |> Phoenix.HTML.safe_to_string() - end - - test "ignores valid but irrelevant escape codes" do - assert ~s{cat} == ANSI.ansi_string_to_html("\e[H\e[1Acat") |> Phoenix.HTML.safe_to_string() - end - - test "returns the whole string if on ANSI code detected" do - assert ~s{\e[300mcat} == - ANSI.ansi_string_to_html("\e[300mcat") |> Phoenix.HTML.safe_to_string() - end - - test "escapes HTML in the resulting string" do - assert ~s{<div>} == ANSI.ansi_string_to_html("
") |> Phoenix.HTML.safe_to_string() - end - - test "given custom renderer uses it to generate HTML" do - div_renderer = fn - "", content -> content - style, content -> [~s{
}, content, ~s{
}] - end - - assert ~s{
cat
} == - ANSI.ansi_string_to_html("\e[34mcat\e[0m", renderer: div_renderer) - |> Phoenix.HTML.safe_to_string() - end - - test "given custom renderer uses it for style-less text as well" do - div_renderer = fn _style, content -> - [~s{
}, content, ~s{
}] - end - - assert ~s{
cat
} == - ANSI.ansi_string_to_html("cat", renderer: div_renderer) - |> Phoenix.HTML.safe_to_string() - end - - test "ignores RFC 1468 switch to ASCII" do - assert ~s{cat} == ANSI.ansi_string_to_html("\e(Bcat") |> Phoenix.HTML.safe_to_string() - end - end -end diff --git a/test/livebook_web/helpers/ansi_test.exs b/test/livebook_web/helpers/ansi_test.exs new file mode 100644 index 000000000..18562a2f3 --- /dev/null +++ b/test/livebook_web/helpers/ansi_test.exs @@ -0,0 +1,57 @@ +defmodule LivebookWeb.Helpers.ANSITest do + use ExUnit.Case, async: true + + alias LivebookWeb.Helpers.ANSI + + describe "ansi_string_to_html/2" do + test "converts ANSI escape codes to span tags" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[34mcat\e[0m") |> Phoenix.HTML.safe_to_string() + + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[44mcat\e[0m") |> Phoenix.HTML.safe_to_string() + + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[1mcat\e[0m") |> Phoenix.HTML.safe_to_string() + + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[4mcat\e[0m") |> Phoenix.HTML.safe_to_string() + end + + test "renders 8-bit rgb colors as regular rgb" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[38;5;67mcat\e[0m") |> Phoenix.HTML.safe_to_string() + end + + test "renders 8-bit grayscale as regular rgb" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[38;5;240mcat\e[0m") |> Phoenix.HTML.safe_to_string() + end + + test "escapes HTML in the resulting string" do + assert ~s{<div>} == ANSI.ansi_string_to_html("
") |> Phoenix.HTML.safe_to_string() + end + end + + describe "ansi_string_to_html_lines/1" do + test "renders every line as complete HTML" do + assert ["cool", "cat"] == + ANSI.ansi_string_to_html_lines("cool\ncat") + |> Enum.map(&Phoenix.HTML.safe_to_string/1) + + assert [ + ~s{cool}, + ~s{cat} + ] == + ANSI.ansi_string_to_html_lines("\e[34mcool\ncat\e[0m") + |> Enum.map(&Phoenix.HTML.safe_to_string/1) + + assert [ + ~s{coolcats}, + ~s{chillin} + ] == + ANSI.ansi_string_to_html_lines("\e[34mcool\e[32mcats\n\e[0mchillin") + |> Enum.map(&Phoenix.HTML.safe_to_string/1) + end + end +end diff --git a/test/livebook_web/helpers_test.exs b/test/livebook_web/helpers_test.exs index b197c80e1..5e2c5545c 100644 --- a/test/livebook_web/helpers_test.exs +++ b/test/livebook_web/helpers_test.exs @@ -3,16 +3,6 @@ defmodule LivebookWeb.HelpersTest do alias LivebookWeb.Helpers - describe "ansi_to_html_lines/1" do - test "puts every line in its own tag" do - assert [ - {:safe, ~s{smiley}}, - {:safe, ~s{cat}} - ] == - Helpers.ansi_to_html_lines("\e[34msmiley\ncat\e[0m") - end - end - describe "names_to_html_ids/1" do test "title case" do assert(Helpers.names_to_html_ids(["Title of a Section"]) == ["title-of-a-section"])