Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend ANSI code parser to handle multiple arguments #569

Merged
merged 2 commits into from
Sep 30, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Added support for configuring file systems using env variables ([#498](https://github.com/livebook-dev/livebook/pull/498))
- Added a keyboard shortcut for triggering on-hover docs ([#508](https://github.com/livebook-dev/livebook/pull/508))
- Added `--open-new` CLI flag to `livebook server` ([#529](https://github.com/livebook-dev/livebook/pull/529))
- Nx introductory notebook ([#528](https://github.com/livebook-dev/livebook/pull/528))

### Changed

Expand All @@ -25,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Improved the evaluator process to not consume user-submitted messages from inbox ([#502](https://github.com/livebook-dev/livebook/pull/502))
- Improved sections panel UI to better handle numerous sections or long section names ([#534](https://github.com/livebook-dev/livebook/pull/534) and [#537](https://github.com/livebook-dev/livebook/pull/537))
- Fixed branching section evaluation when the parent section is empty ([#560](https://github.com/livebook-dev/livebook/pull/560)
- Fixed ANSI support to handle multi-code escape sequences ([#569](https://github.com/livebook-dev/livebook/pull/569)

## [v0.2.3](https://github.com/livebook-dev/livebook/tree/v0.2.3) (2021-08-12)

Expand Down
204 changes: 122 additions & 82 deletions lib/livebook/utils/ansi.ex
Original file line number Diff line number Diff line change
@@ -1,20 +1,6 @@
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}
Expand Down Expand Up @@ -54,12 +40,12 @@ defmodule Livebook.Utils.ANSI do
{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)
case ansi_prefix_to_modifiers(string) do
{:ok, new_modifiers, rest} ->
modifiers = Enum.reduce(new_modifiers, modifiers, &apply_modifier(&2, &1))
{modifiers, rest}

{:error, _rest} ->
:error ->
{modifiers, "\e" <> string}
end

Expand All @@ -83,83 +69,137 @@ defmodule Livebook.Utils.ANSI 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)
defp ansi_prefix_to_modifiers("[1A" <> rest), do: {:ok, [:ignored], rest}
defp ansi_prefix_to_modifiers("[1B" <> rest), do: {:ok, [:ignored], rest}
defp ansi_prefix_to_modifiers("[1C" <> rest), do: {:ok, [:ignored], rest}
defp ansi_prefix_to_modifiers("[1D" <> rest), do: {:ok, [:ignored], rest}
defp ansi_prefix_to_modifiers("[2J" <> rest), do: {:ok, [:ignored], rest}
defp ansi_prefix_to_modifiers("[2K" <> rest), do: {:ok, [:ignored], rest}
defp ansi_prefix_to_modifiers("[H" <> rest), do: {:ok, [:ignored], rest}

# When the code is missing (i.e., "\e[m"), it is 0 for reset.
defmodifier(:reset, "")
# "\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_modifiers("(B" <> rest), do: {:ok, [:ignored], rest}

defp ansi_prefix_to_modifiers("[" <> rest) do
with [args_string, rest] <- String.split(rest, "m", parts: 2),
{:ok, args} <- parse_ansi_args(args_string),
{:ok, modifiers} <- ansi_args_to_modifiers(args, []) do
{:ok, modifiers, rest}
else
_ -> :error
end
end

@colors [:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white]
defp ansi_prefix_to_modifiers(_string), do: :error

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)
defp parse_ansi_args(args_string) do
args_string
|> String.split(";")
|> Enum.reduce_while([], fn arg, parsed ->
case parse_ansi_arg(arg) do
{:ok, n} -> {:cont, [n | parsed]}
:error -> {:halt, :error}
end
end)
|> case do
:error -> :error
parsed -> {:ok, Enum.reverse(parsed)}
end
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}
defp parse_ansi_arg(""), do: {:ok, 0}

defp parse_ansi_arg(string) do
case Integer.parse(string) do
{n, ""} -> {:ok, n}
_ -> :error
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}
defp ansi_args_to_modifiers([], acc), do: {:ok, Enum.reverse(acc)}

defp ansi_args_to_modifiers(args, acc) do
case ansi_args_to_modifier(args) do
{:ok, modifier, args} -> ansi_args_to_modifiers(args, [modifier | acc])
:error -> :error
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
@colors [:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white]

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}
defp ansi_args_to_modifier(args) do
case args do
[0 | args] ->
{:ok, :reset, args}

_ ->
{:error, string}
end
end
[1 | args] ->
{:ok, {:font_weight, :bold}, args}

ignored_codes = [5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 25, 27, 51, 52, 54]
[2 | args] ->
{:ok, {:font_weight, :light}, args}

for code <- ignored_codes do
defmodifier(:ignored, code)
end
[3 | args] ->
{:ok, {:font_style, :italic}, args}

[4 | args] ->
{:ok, {:text_decoration, :underline}, args}

[9 | args] ->
{:ok, {:text_decoration, :line_through}, args}

[22 | args] ->
{:ok, {:font_weight, :reset}, args}

[23 | args] ->
{:ok, {:font_style, :reset}, args}

[24 | args] ->
{:ok, {:text_decoration, :reset}, args}

[n | args] when n in 30..37 ->
color = Enum.at(@colors, n - 30)
{:ok, {:foreground_color, color}, args}

[38, 5, bit8 | args] when bit8 in 0..255 ->
color = color_from_code(bit8)
{:ok, {:foreground_color, color}, args}

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")
[39 | args] ->
{:ok, {:foreground_color, :reset}, args}

defp ansi_prefix_to_modifier(string), do: {:error, string}
[n | args] when n in 40..47 ->
color = Enum.at(@colors, n - 40)
{:ok, {:background_color, color}, args}

[48, 5, bit8 | args] when bit8 in 0..255 ->
color = color_from_code(bit8)
{:ok, {:background_color, color}, args}

[49 | args] ->
{:ok, {:background_color, :reset}, args}

[53 | args] ->
{:ok, {:text_decoration, :overline}, args}

[55 | args] ->
{:ok, {:text_decoration, :reset}, args}

[n | args] when n in 90..97 ->
color = Enum.at(@colors, n - 90)
{:ok, {:foreground_color, :"light_#{color}"}, args}

[n | args] when n in 100..107 ->
color = Enum.at(@colors, n - 100)
{:ok, {:background_color, :"light_#{color}"}, args}

[n | args] when n <= 107 ->
{:ok, :ignored, args}

_ ->
:error
end
end

defp color_from_code(code) when code in 0..7 do
Enum.at(@colors, code)
Expand All @@ -184,8 +224,8 @@ defmodule Livebook.Utils.ANSI do
{: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)
defp apply_modifier(modifiers, :ignored), do: modifiers
defp apply_modifier(_modifiers, :reset), do: %{}
defp apply_modifier(modifiers, {key, :reset}), do: Map.delete(modifiers, key)
defp apply_modifier(modifiers, {key, value}), do: Map.put(modifiers, key, value)
end
14 changes: 13 additions & 1 deletion test/livebook/utils/ansi_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,24 @@ defmodule Livebook.Utils.ANSITest do
assert ANSI.parse_ansi_string("\e[H\e[1Acat") == [{[], "cat"}]
end

test "returns the whole string if on ANSI code detected" do
test "returns the whole string if no ANSI code is detected" do
assert ANSI.parse_ansi_string("\e[300mcat") == [{[], "\e[300mcat"}]
assert ANSI.parse_ansi_string("\ehmmcat") == [{[], "\ehmmcat"}]
end

test "ignores RFC 1468 switch to ASCII" do
assert ANSI.parse_ansi_string("\e(Bcat") == [{[], "cat"}]
end

test "supports multiple codes separated by semicolon" do
assert ANSI.parse_ansi_string("\e[0;34mcat") == [{[foreground_color: :blue], "cat"}]

assert ANSI.parse_ansi_string("\e[34;41mcat\e[0m") ==
[{[background_color: :red, foreground_color: :blue], "cat"}]

# 8-bit rgb color followed by background color
assert ANSI.parse_ansi_string("\e[38;5;67;41mcat\e[0m") ==
[{[background_color: :red, foreground_color: {:rgb6, 1, 2, 3}], "cat"}]
end
end
end