defmodule Athena do @ansi_escape_sequences ~r(/\e|\033|\x1B/) @ansi_codes %{ "0m" => "", "1m" => "", "4m" => "", "30m" => "", "31m" => "", "32m" => "", "33m" => "", "34m" => "", "35m" => "", "36m" => "", "37m" => "", "40m" => "", "41m" => "", "42m" => "", "43m" => "", "44m" => "", "45m" => "", "46m" => "", "47m" => "", "90m" => "", "91m" => "", "92m" => "", "93m" => "", "94m" => "", "95m" => "", "96m" => "", "97m" => "", "100m" => "", "101m" => "", "102m" => "", "103m" => "", "104m" => "", "105m" => "", "106m" => "", "107m" => "" } @doc """ Returns the current list of `ansi_codes` in "lexigraphical" order """ def ansi_codes, do: @ansi_codes |> Enum.sort() @doc ~S""" Parses an `ansi_string` and returns the HTML output. ## Examples iex> Athena.ansi_to_html("\e[4mfoo\e[0m") "foo" It returns normal text when normal text is passed in iex> Athena.ansi_to_html("normal text") "normal text" Background Colors iex> Athena.ansi_to_html("My Background is \e[42mGreen\e[0m") "My Background is Green" Foreground Colors iex> Athena.ansi_to_html("This text is \e[91mLight red\e[0m") "This text is Light red" Kitchen sink iex> Athena.ansi_to_html("1 scenario (\e[31m1 failed\e[0m)\n8 steps (\e[31m1 failed\e[0m, \e[36m1 skipped\e[0m, \e[32m6 passed\e[0m)\n0m26.474s") "1 scenario (1 failed)\n8 steps (1 failed, 1 skipped, 6 passed)\n0m26.474s" """ def ansi_to_html(ansi_block) do ansi_block |> escape_html_brackets() |> split_on_escapes() |> replace() end defp split_on_escapes(ansi_block) do ansi_block |> String.split(@ansi_escape_sequences) |> Enum.reject(&(&1 === "")) end defp replace(ansi_list, opened_tags \\ 0) do [head | tail] = ansi_list case tail do [] -> replace_item(head, opened_tags) _ -> new_opened_tags = calculate_new_tags(head, opened_tags) replace_item(head, opened_tags) <> replace(tail, new_opened_tags) end end defp calculate_new_tags(head, opened_tags) do cond do String.starts_with?(head, "[0m") -> 0 String.starts_with?(head, Enum.map(Map.keys(@ansi_codes), fn key -> "[" <> key end)) -> opened_tags + 1 true -> opened_tags end end defp replace_item(item, opened_tags) do case Regex.run(~r/\A\[(\d+)m/, item) do nil -> item [_, "0"] -> close_sequence(item, opened_tags) _ -> Regex.replace(~r/\[(\d+m)/, item, fn _, x -> fetch_ansi_code(x) end) end end defp fetch_ansi_code(code) do case Map.fetch(@ansi_codes, code) do :error -> raise("No translation found for #{code} in @ansi_codes.") {:ok, html} -> html end end defp close_sequence(item, opened_tags) when opened_tags === 0, do: String.replace(item, "[0m", "") defp close_sequence(item, opened_tags) do String.replace(item, "[0m", Enum.map_join(1..opened_tags, "", fn _ -> "" end)) end defp escape_html_brackets(ansi_block) do ansi_block |> String.replace("<", "<") |> String.replace(">", ">") end end