defmodule Owl.Data do
@moduledoc """
A set of functions for `t:iodata/0` with [tags](`Owl.Tag`).
"""
@typedoc """
A recursive data type that is similar to `t:iodata/0`, but additionally supports `t:Owl.Tag.t/1`.
Can be printed using `Owl.IO.puts/2`.
"""
# improper lists are not here, just because they were not tested
@type t :: [binary() | non_neg_integer() | t() | Owl.Tag.t(t())] | Owl.Tag.t(t()) | binary()
@typedoc """
ANSI escape sequence.
An atom alias of ANSI escape sequence.
A binary representation of color like `"\e[38;5;33m"` (which is `IO.ANSI.color(33)` or `IO.ANSI.color(0, 2, 5)`).
"""
@type sequence ::
:black
| :red
| :green
| :yellow
| :blue
| :magenta
| :cyan
| :white
| :black_background
| :red_background
| :green_background
| :yellow_background
| :blue_background
| :magenta_background
| :cyan_background
| :white_background
| :light_black_background
| :light_red_background
| :light_green_background
| :light_yellow_background
| :light_blue_background
| :light_magenta_background
| :light_cyan_background
| :light_white_background
| :default_color
| :default_background
| :blink_slow
| :blink_rapid
| :faint
| :bright
| :inverse
| :underline
| :italic
| :overlined
| :reverse
| binary()
@doc """
Builds a tag.
## Examples
iex> Owl.Data.tag(["hello ", Owl.Data.tag("world", :green), "!!!"], :red) |> inspect()
~s|#Owl.Tag[:red]<["hello ", #Owl.Tag[:green]<"world">, "!!!"]>|
iex> Owl.Data.tag("hello world", [:green, :red_background]) |> inspect()
~s|#Owl.Tag[:green, :red_background]<"hello world">|
"""
@spec tag(data, sequence() | [sequence()]) :: Owl.Tag.t(data) when data: t()
def tag(data, sequence_or_sequences) do
%Owl.Tag{
sequences: List.wrap(sequence_or_sequences),
data: data
}
end
@doc """
Removes information about sequences and keeps only content of the tag.
## Examples
iex> Owl.Data.tag("Hello", :red) |> Owl.Data.untag()
"Hello"
iex> Owl.Data.tag([72, 101, 108, 108, 111], :red) |> Owl.Data.untag()
'Hello'
iex> Owl.Data.tag(["Hello", Owl.Data.tag("world", :green)], :red) |> Owl.Data.untag()
["Hello", "world"]
iex> ["Hello ", Owl.Data.tag("world", :red), ["!"]] |> Owl.Data.untag()
["Hello ", "world", ["!"]]
"""
@spec untag(t()) :: iodata()
def untag(data) when is_list(data) do
Enum.map(data, &untag_child/1)
end
def untag(%Owl.Tag{data: data}) do
untag(data)
end
def untag(data) when is_binary(data) do
data
end
defp untag_child(data) when is_list(data) do
Enum.map(data, &untag_child/1)
end
defp untag_child(%Owl.Tag{data: data}) do
data
end
defp untag_child(data) when is_binary(data) do
data
end
defp untag_child(data) when is_integer(data) do
data
end
@doc """
Zips corresponding lines into 1 line.
The zipping finishes as soon as either data completes.
## Examples
iex> Owl.Data.zip("a\\nb\\nc", "d\\ne\\nf")
[["a", "d"], "\\n", ["b", "e"], "\\n", ["c", "f"]]
iex> Owl.Data.zip("a\\nb", "c")
[["a", "c"]]
iex> 1..3
...> |> Enum.map(&to_string/1)
...> |> Enum.map(&Owl.Box.new/1) |> Enum.reduce(&Owl.Data.zip/2) |> to_string()
\"""
┌─┐┌─┐┌─┐
│3││2││1│
└─┘└─┘└─┘
\""" |> String.trim_trailing()
"""
@spec zip(t(), t()) :: t()
def zip(data1, data2) do
lines1 = lines(data1)
lines2 = lines(data2)
lines1
|> Enum.zip_with(lines2, &[&1, &2])
|> unlines()
end
@doc """
Returns length of the data.
## Examples
iex> Owl.Data.length(["222"])
3
iex> Owl.Data.length([222])
1
iex> Owl.Data.length([[[]]])
0
iex> Owl.Data.length(["222", Owl.Data.tag(["333", "444"], :green)])
9
"""
@spec length(t()) :: non_neg_integer()
def length(data) when is_binary(data) do
String.length(data)
end
def length(data) when is_list(data) do
import Kernel, except: [length: 1]
Enum.reduce(data, 0, fn
item, acc when is_integer(item) -> length(<<item::utf8>>) + acc
item, acc -> length(item) + acc
end)
end
def length(%Owl.Tag{data: data}) do
import Kernel, except: [length: 1]
length(data)
end
@doc """
Splits data by new lines.
A special case of `split/2`.
## Example
iex> Owl.Data.lines(["first\\nsecond\\n", Owl.Data.tag("third\\nfourth", :red)])
["first", "second", Owl.Data.tag(["third"], :red), Owl.Data.tag(["fourth"], :red)]
"""
@spec lines(t()) :: [t()]
def lines(data) do
split(data, "\n")
end
@doc """
Creates a `t:t/0` from an a list of `t:t/0`, it inserts new line characters between original elements.
## Examples
iex> Owl.Data.unlines(["a", "b", "c"])
["a", "\\n", "b", "\\n", "c"]
iex> ["first\\nsecond\\n", Owl.Data.tag("third\\nfourth", :red)]
...> |> Owl.Data.lines()
...> |> Owl.Data.unlines()
...> |> Owl.Data.to_ansidata()
Owl.Data.to_ansidata(["first\\nsecond\\n", Owl.Data.tag("third\\nfourth", :red)])
"""
@spec unlines([t()]) :: [t()]
def unlines(data) do
Enum.intersperse(data, "\n")
end
@doc """
Adds a `prefix` before each line of the `data`.
An important feature is that styling of the data will be saved for each line.
## Example
iex> "first\\nsecond" |> Owl.Data.tag(:red) |> Owl.Data.add_prefix(Owl.Data.tag("test: ", :yellow))
[
[Owl.Data.tag("test: ", :yellow), Owl.Data.tag(["first"], :red)],
"\\n",
[Owl.Data.tag("test: ", :yellow), Owl.Data.tag(["second"], :red)]
]
"""
@spec add_prefix(t(), t()) :: t()
def add_prefix(data, prefix) do
data
|> lines()
|> Enum.map(fn line -> [prefix, line] end)
|> unlines()
end
@doc """
Transforms data to `t:IO.ANSI.ansidata/0` format which can be consumed by `IO` module.
## Examples
iex> "hello" |> Owl.Data.tag([:red, :cyan_background]) |> Owl.Data.to_ansidata()
[[[[[[[] | "\e[46m"] | "\e[31m"], "hello"] | "\e[39m"] | "\e[49m"] | "\e[0m"]
"""
@spec to_ansidata(t()) :: IO.ANSI.ansidata()
def to_ansidata(data) do
# combination of lines + unlines is needed in order to break background and do not spread it to the end of the line
data
|> lines()
|> unlines()
|> do_to_ansidata(%{})
|> IO.ANSI.format()
end
defp do_to_ansidata(
%Owl.Tag{sequences: sequences, data: data},
open_tags
) do
new_open_tags = sequences_to_state(open_tags, sequences)
close_tags =
Enum.reduce(new_open_tags, [], fn {sequence_type, sequence}, acc ->
case Map.get(open_tags, sequence_type) do
nil ->
return_to = default_value_by_sequence_type(sequence_type)
[return_to | acc]
previous_sequence ->
if previous_sequence == sequence do
acc
else
[previous_sequence | acc]
end
end
end)
[sequences, do_to_ansidata(data, new_open_tags), close_tags]
end
defp do_to_ansidata(list, open_tags) when is_list(list) do
Enum.map(list, &do_to_ansidata(&1, open_tags))
end
defp do_to_ansidata(term, _open_tags), do: term
defp maybe_wrap_to_tag([], [element]), do: element
defp maybe_wrap_to_tag([], data), do: data
defp maybe_wrap_to_tag(sequences1, [%Owl.Tag{sequences: sequences2, data: data}]) do
tag(data, collapse_sequences(sequences1 ++ sequences2))
end
defp maybe_wrap_to_tag(sequences, data) do
tag(data, collapse_sequences(sequences))
end
defp reverse_and_tag(sequences, [%Owl.Tag{sequences: last_sequences} | _] = data) do
maybe_wrap_to_tag(sequences -- last_sequences, Enum.reverse(data))
end
defp reverse_and_tag(sequences, data) do
maybe_wrap_to_tag(sequences, Enum.reverse(data))
end
# last write wins
defp collapse_sequences(sequences) do
%{foreground: nil, background: nil}
|> sequences_to_state(sequences)
|> Map.values()
|> Enum.reject(&is_nil/1)
end
@doc """
Divides data into parts based on a pattern saving sequences for tagged data in new tags.
## Example
iex> Owl.Data.split(["first second ", Owl.Data.tag("third fourth", :red)], " ")
["first", "second", Owl.Data.tag(["third"], :red), Owl.Data.tag(["fourth"], :red)]
iex> Owl.Data.split(["first second ", Owl.Data.tag("third fourth", :red)], ~r/\s+/)
["first", "second", Owl.Data.tag(["third"], :red), Owl.Data.tag(["fourth"], :red)]
"""
@spec split(t(), String.pattern() | Regex.t()) :: [t()]
def split(data, pattern) do
chunk_by(
data,
nil,
fn value, nil ->
[head | tail] = String.split(value, pattern, parts: 2)
head = if head == "", do: [], else: head
resolution = if tail == [], do: :cont, else: :chunk
{resolution, nil, head, tail}
end
)
end
defp sequences_to_state(init, sequences) do
Enum.reduce(sequences, init, fn sequence, acc ->
Map.put(acc, sequence_type(sequence), sequence)
end)
end
for color <- [:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white] do
defp sequence_type(unquote(color)), do: :foreground
defp sequence_type(unquote(:"light_#{color}")), do: :foreground
defp sequence_type(unquote(:"#{color}_background")), do: :background
defp sequence_type(unquote(:"light_#{color}_background")), do: :background
end
defp sequence_type(:default_color), do: :foreground
defp sequence_type(:default_background), do: :background
defp sequence_type(:blink_slow), do: :blink
defp sequence_type(:blink_rapid), do: :blink
defp sequence_type(:faint), do: :intensity
defp sequence_type(:bright), do: :intensity
defp sequence_type(:inverse), do: :inverse
defp sequence_type(:underline), do: :underline
defp sequence_type(:italic), do: :italic
defp sequence_type(:overlined), do: :overlined
defp sequence_type(:reverse), do: :reverse
# https://github.com/elixir-lang/elixir/blob/74bfab8ee271e53d24cb0012b5db1e2a931e0470/lib/elixir/lib/io/ansi.ex#L73
defp sequence_type("\e[38;5;" <> _), do: :foreground
# https://github.com/elixir-lang/elixir/blob/74bfab8ee271e53d24cb0012b5db1e2a931e0470/lib/elixir/lib/io/ansi.ex#L87
defp sequence_type("\e[48;5;" <> _), do: :background
defp default_value_by_sequence_type(:foreground), do: :default_color
defp default_value_by_sequence_type(:background), do: :default_background
defp default_value_by_sequence_type(:blink), do: :blink_off
defp default_value_by_sequence_type(:intensity), do: :normal
defp default_value_by_sequence_type(:inverse), do: :inverse_off
defp default_value_by_sequence_type(:underline), do: :no_underline
defp default_value_by_sequence_type(:italic), do: :not_italic
defp default_value_by_sequence_type(:overlined), do: :not_overlined
defp default_value_by_sequence_type(:reverse), do: :reverse_off
@doc """
Truncates data, so the length of returning data is <= `length`.
Puts ellipsis symbol at the end if data was truncated.
## Examples
iex> Owl.Data.truncate([Owl.Data.tag("Hello", :red), Owl.Data.tag(" world!", :green)], 10)
[Owl.Data.tag(["Hello"], :red), Owl.Data.tag([" wor"], :green), "…"]
iex> Owl.Data.truncate("Hello", 10)
"Hello"
iex> Owl.Data.truncate("Hello", 4)
["Hel", "…"]
iex> Owl.Data.truncate("Hello", 5)
"Hello"
"""
@spec truncate(t(), pos_integer()) :: t()
def truncate(data, length) when length > 0 do
import Kernel, except: [length: 1]
if length(data) > length do
data |> slice(0, length - 1) |> List.wrap() |> Enum.concat(["…"])
else
data
end
end
@doc """
Returns a data starting at the offset `start`, and of the given `length`.
It is like `String.slice/3` but for `t:t/0`.
## Examples
iex> Owl.Data.slice([Owl.Data.tag("Hello world", :red), Owl.Data.tag("!", :green)], 6, 7)
[Owl.Data.tag(["world"], :red), Owl.Data.tag(["!"], :green)]
iex> Owl.Data.slice(Owl.Data.tag("Hello world", :red), 20, 10)
[]
"""
@spec slice(t(), integer(), pos_integer()) :: t()
def slice(data, start, length) when is_integer(start) and is_integer(length) and length > 0 do
result =
chunk_by(data, {start, length}, fn value, {start, length} ->
value_length = String.length(value)
if value_length <= start do
{:cont, {start - value_length, length}, [], []}
else
result = String.slice(value, start, length)
case length - String.length(result) do
0 -> {:halt, result}
new_length -> {:cont, {0, new_length}, result, []}
end
end
end)
# cleanup output, so it just looks prettier
result
|> trim_leading_blank_tags()
|> maybe_unwrap_list()
end
defp maybe_unwrap_list([item]), do: item
defp maybe_unwrap_list(items), do: items
defp trim_leading_blank_tags([%Owl.Tag{data: []} | tail]) do
trim_leading_blank_tags(tail)
end
defp trim_leading_blank_tags(result), do: result
@doc """
Returns list of `t()` containing `count` elements each.
## Example
iex> Owl.Data.chunk_every(
...> ["first second ", Owl.Data.tag(["third", Owl.Data.tag(" fourth", :blue)], :red)],
...> 7
...> )
[
"first s",
["econd ", Owl.Data.tag(["t"], :red)],
Owl.Data.tag(["hird", Owl.Data.tag([" fo"], :blue)], :red),
Owl.Data.tag(["urth"], :blue)
]
"""
@spec chunk_every(data :: t(), count :: pos_integer()) :: [t()]
def chunk_every(data, count) when count > 0 do
chunk_by(
data,
0,
fn value, cut_left ->
split_at = if cut_left == 0, do: count, else: cut_left
case String.split_at(value, split_at) do
{head, ""} ->
left = split_at - String.length(head)
resolution = if left == 0, do: :chunk, else: :cont
{resolution, left, head, []}
{head, rest} ->
{:chunk, 0, head, [rest]}
end
end
)
end
defp chunk_by(data, chunk_acc, chunk_fun), do: chunk_by(data, chunk_acc, chunk_fun, [])
defp chunk_by([], _chunk_acc, _chunk_fun, _acc_sequences), do: []
defp chunk_by(data, chunk_acc, chunk_fun, acc_sequences) do
case do_chunk_by(data, chunk_acc, chunk_fun, [], acc_sequences) do
{:halt, head, next_acc_sequences} ->
reverse_and_tag(acc_sequences ++ next_acc_sequences, head)
{_, head, tail, chunk_acc, next_acc_sequences} ->
[
reverse_and_tag(acc_sequences ++ next_acc_sequences, head)
| chunk_by(tail, chunk_acc, chunk_fun, next_acc_sequences)
]
end
end
defp do_chunk_by([head | tail], chunk_acc, chunk_fun, acc, acc_sequences) do
case do_chunk_by(head, chunk_acc, chunk_fun, acc, acc_sequences) do
{:cont, new_head, new_tail, chunk_acc, new_acc_sequences} ->
new_tail
|> put_nonempty_head(tail)
|> do_chunk_by(chunk_acc, chunk_fun, new_head, new_acc_sequences)
{:halt, new_head, new_acc_sequences} ->
new_acc_sequences =
case new_head do
[%Owl.Tag{sequences: sequences} | _] -> new_acc_sequences -- sequences
_ -> new_acc_sequences
end
{:halt, new_head, new_acc_sequences}
{:chunk, new_head, new_tail, chunk_acc, new_acc_sequences} ->
new_tail = maybe_wrap_to_tag(new_acc_sequences -- acc_sequences, new_tail)
new_acc_sequences =
case new_head do
[%Owl.Tag{sequences: sequences} | _] -> new_acc_sequences -- sequences
_ -> new_acc_sequences
end
new_head =
case new_head do
[%Owl.Tag{data: []} | rest] -> rest
list -> list
end
new_tail = put_nonempty_head(new_tail, tail)
{:chunk, new_head, new_tail, chunk_acc, new_acc_sequences}
end
end
defp do_chunk_by([], chunk_acc, _chunk_fun, acc, acc_sequences) do
{:cont, acc, [], chunk_acc, acc_sequences}
end
defp do_chunk_by(
%Owl.Tag{sequences: sequences, data: data},
chunk_acc,
chunk_fun,
acc,
acc_sequences
) do
case do_chunk_by(data, chunk_acc, chunk_fun, [], acc_sequences ++ sequences) do
{:halt, head, next_acc_sequences} ->
head = reverse_and_tag(sequences, head)
next_acc_sequences = next_acc_sequences -- sequences
{:halt, [head | acc], next_acc_sequences}
{resolution, head, tail, chunk_acc, next_acc_sequences} ->
head = reverse_and_tag(sequences, head)
next_acc_sequences =
case tail do
[] -> next_acc_sequences -- sequences
[""] -> next_acc_sequences -- sequences
_ -> next_acc_sequences
end
{resolution, [head | acc], tail, chunk_acc, next_acc_sequences}
end
end
defp do_chunk_by(value, chunk_acc, chunk_fun, acc, acc_sequences) when is_binary(value) do
case chunk_fun.(value, chunk_acc) do
{:halt, head} ->
{:halt, put_nonempty_head(head, acc), acc_sequences}
{resolution, new_chunk_acc, head, rest} ->
{
resolution,
put_nonempty_head(head, acc),
rest,
new_chunk_acc,
acc_sequences
}
end
end
defp do_chunk_by(value, chunk_acc, chunk_fun, acc, acc_sequences) when is_integer(value) do
do_chunk_by(<<value::utf8>>, chunk_acc, chunk_fun, acc, acc_sequences)
end
defp put_nonempty_head([], tail), do: tail
defp put_nonempty_head(head, tail), do: [head | tail]
end