defmodule Owl.IO do
@moduledoc "A set of functions for handling IO with support of `t:Owl.Data.t/0`."
@type select_option :: {:label, Owl.Data.t() | nil} | {:render_as, (any() -> Owl.Data.t())}
@doc """
Selects one item from the given nonempty list.
Returns value immediately if list contains only 1 element.
## Options
* `:label` - a text label. Defaults to `nil` (no label).
* `:render_as` - a function that renders given item. Defaults to `Function.identity/1`.
## Examples
Owl.IO.select(["one", "two", "three"])
#=> 1. one
#=> 2. two
#=> 3. three
#=>
#=> > 1
#=>
"one"
~D[2001-01-01]
|> Date.range(~D[2001-01-03])
|> Enum.to_list()
|> Owl.IO.select(render_as: &Date.to_iso8601/1, label: "Please select a date")
#=> 1. 2001-01-01
#=> 2. 2001-01-02
#=> 3. 2001-01-03
#=>
#=> Please select a date
#=> > 2
#=>
~D[2001-01-02]
packages = [
%{name: "elixir", description: "programming language"},
%{name: "asdf", description: "version manager"},
%{name: "neovim", description: "fork of vim"}
]
Owl.IO.select(packages,
render_as: fn %{name: name, description: description} ->
[Owl.Data.tag(name, :cyan), "\\n ", Owl.Data.tag(description, :light_black)]
end
)
#=> 1. elixir
#=> programming language
#=> 2. asdf
#=> version manager
#=> 3. neovim
#=> fork of vim
#=>
#=> > 3
#=>
%{description: "fork of vim", name: "neovim"}
"""
@spec select(nonempty_list(item), [select_option()]) :: item when item: any()
def select([_ | _] = list, opts \\ []) do
label = Keyword.get(opts, :label)
render_item = Keyword.get(opts, :render_as, &Function.identity/1)
case list do
[item] ->
if label, do: puts(label)
puts(["Autoselect: ", render_item.(item), "\n"])
item
list ->
list
|> Enum.with_index(1)
|> puts_ordered_list(render_item)
IO.puts([])
index = input(cast: {:integer, min: 1, max: length(list)}, label: label) - 1
Enum.at(list, index)
end
end
@type multiselect_option ::
{:label, Owl.Data.t() | nil}
| {:render_as, (any() -> Owl.Data.t())}
| {:min, non_neg_integer() | nil}
| {:max, non_neg_integer() | nil}
@doc """
Select multiple values from the given nonempty list.
Input item numbers must be separated by any non-digit character. Most likely you'd want to use spaces or commas.
It is possible to specify a range of numbers using hyphen.
## Options
* `:label` - a text label. Defaults to `nil` (no label).
* `:render_as` - a function that renders given item. Defaults to `Function.identity/1`.
* `:min` - a minimum output list length. Defaults to `nil` (no lower bound).
* `:max` - a maximum output list length. Defaults to `nil` (no upper bound).
## Examples
Owl.IO.multiselect(["one", "two", "three"], min: 2, label: "Select 2 numbers:", render_as: &String.upcase/1)
#=> 1. ONE
#=> 2. TWO
#=> 3. THREE
#=>
#=> Select 2 numbers:
#=> > 1
#=> the number of elements must be greater than or equal to 2
#=> Select 2 numbers:
#=> > 1 3
#=>
["one", "three"]
Owl.IO.multiselect(Enum.to_list(1..5), render_as: &to_string/1)
#=> 1. 1
#=> 2. 2
#=> 3. 3
#=> 4. 4
#=> 5. 5
#=>
#=> > 1-3 5
#=>
[1, 2, 3, 5]
"""
@spec multiselect(nonempty_list(item), [multiselect_option()]) :: [item] when item: any()
def multiselect([_ | _] = list, opts \\ []) do
label = Keyword.get(opts, :label)
render_item = Keyword.get(opts, :render_as, &Function.identity/1)
min_elements = Keyword.get(opts, :min)
max_elements = Keyword.get(opts, :max)
ordered_list = Enum.with_index(list, 1)
indexed_values = Map.new(ordered_list, fn {value, index} -> {index, value} end)
list_size = map_size(indexed_values)
if is_integer(min_elements) and min_elements > list_size do
raise ArgumentError, "input list must contain at least #{min_elements} elements"
end
puts_ordered_list(ordered_list, render_item)
IO.puts([])
bounds = 1..list_size
numbers =
input(
cast: &cast_multiselect_input(&1, bounds, min_elements, max_elements),
label: label,
optional: true
)
indexed_values |> Map.take(numbers) |> Map.values()
end
defp cast_multiselect_input(value, bounds, min_elements, max_elements) do
numbers =
~r/(\d+)\-?(\d+)?/
|> Regex.scan(to_string(value), capture: :all_but_first)
|> Enum.flat_map(fn
[string] -> [String.to_integer(string)]
[first, second] -> Enum.to_list(String.to_integer(first)..String.to_integer(second))
end)
|> Enum.uniq()
case Enum.reject(numbers, &(&1 in bounds)) do
[] ->
numbers_length = length(numbers)
with :ok <- validate_bounds(numbers_length, :min, min_elements),
:ok <- validate_bounds(numbers_length, :max, max_elements) do
{:ok, numbers}
else
{:error, reason} -> {:error, "the number of elements #{reason}"}
end
invalid_numbers ->
{:error, "unknown values: #{Kernel.inspect(invalid_numbers, charlists: :as_lists)}"}
end
end
defp puts_ordered_list(ordered_list, render_item) do
last_index_width =
ordered_list |> Enum.reverse() |> hd() |> elem(1) |> to_string() |> String.length()
# 2 is length of ". "
max_width = last_index_width + 2
ordered_list
|> Enum.map(fn {item, index} ->
rendered_item = render_item.(item)
[Owl.Data.tag(to_string(index), :blue), ". "]
|> Owl.Box.new(
border_style: :none,
min_height: length(Owl.Data.lines(rendered_item)),
min_width: max_width,
horizontal_align: :right
)
|> Owl.Data.zip(rendered_item)
end)
|> Owl.Data.unlines()
|> puts()
end
@doc """
Opens `data` in editor for editing.
Returns updated data when file is saved and editor is closed.
Similarly to `IEx.Helpers.open/1`, this function uses `ELIXIR_EDITOR` environment variable by default.
`__FILE__` notation is supported as well.
## Example
# use neovim in alacritty terminal emulator as an editor
$ export ELIXIR_EDITOR="alacritty -e nvim"
# open editor from Elixir code
Owl.IO.open_in_editor("hello\\nworld")
# specify editor explicitly
Owl.IO.open_in_editor("hello\\nworld", "alacritty -e nvim")
"""
@spec open_in_editor(iodata()) :: String.t()
def open_in_editor(data, elixir_editor \\ System.fetch_env!("ELIXIR_EDITOR")) do
dir = System.tmp_dir!()
filename = "owl-#{random_string()}"
tmp_file = Path.join(dir, filename)
File.write!(tmp_file, data)
elixir_editor =
if String.contains?(elixir_editor, "__FILE__") do
String.replace(elixir_editor, "__FILE__", tmp_file)
else
elixir_editor <> " " <> tmp_file
end
{_, 0} = System.shell(elixir_editor)
File.read!(tmp_file)
end
defp random_string do
length = 9
length
|> :crypto.strong_rand_bytes()
|> Base.url_encode64()
|> binary_part(0, length)
end
@type confirm_option ::
{:message, Owl.Data.t()}
| {:default, boolean()}
| {:answers,
[
true: {primary_true_answer :: binary(), other_true_answers :: [binary()]},
false: {primary_false_answer :: binary(), other_false_answers :: [binary()]}
]}
@default_confirmation_message "Are you sure?"
@doc """
Asks user to type a confirmation.
Valid inputs are a blank string and values specified in `:answers` option.
User will be asked to type a confirmation again on invalid input.
## Options
* `:message` - typically a question about performing operation. Defaults to `#{Kernel.inspect(@default_confirmation_message)}`.
* `:default` - a value that is used when user responds with a blank string. Defaults to `false`.
* `:answers` - allows to specify alternative answers. Defaults to `[true: {"y", ["yes"]}, false: {"n", ["no"]}]`.
## Examples
Owl.IO.confirm()
#=> Are you sure? [yN] n
false
Owl.IO.confirm(message: Owl.Data.tag("Really?", :red), default: true)
#=> Really? [Yn]
true
Owl.IO.confirm(
message: Owl.Data.tag("Справді?", :red),
answers: [true: {"т", ["так", "y", "yes"]}, false: {"н", ["ні", "n", "no"]}]
)
#=> Справді? [тН] НІ
false
"""
@spec confirm([confirm_option()]) :: boolean()
def confirm(opts \\ []) do
message = Keyword.get(opts, :message, @default_confirmation_message)
default = Keyword.get(opts, :default, false)
{primary_true_answer, other_true_answers} = get_in(opts, [:answers, true]) || {"y", ["yes"]}
{primary_false_answer, other_false_answers} = get_in(opts, [:answers, false]) || {"n", ["no"]}
answers =
if default do
String.upcase(primary_true_answer) <> String.downcase(primary_false_answer)
else
String.downcase(primary_true_answer) <> String.upcase(primary_false_answer)
end
result = gets(false, [message, " [", answers, "]: "])
cond do
is_nil(result) ->
default
String.downcase(result) in Enum.map(
[primary_true_answer | other_true_answers],
&String.downcase/1
) ->
true
String.downcase(result) in Enum.map(
[primary_false_answer | other_false_answers],
&String.downcase/1
) ->
false
true ->
report_error("unknown answer")
confirm(opts)
end
end
@type cast_input ::
(String.t() | nil -> {:ok, value :: any()} | {:error, reason :: String.Chars.t()})
@type input_option ::
{:label, Owl.Data.t()}
| {:cast, atom() | {atom(), Keyword.t()} | cast_input()}
| {:optional, boolean()}
@doc """
Reads a line from the `stdio` and casts a value to the given type.
After reading a line from `stdio` it will be automatically trimmed with `String.trim/2`.
The end value will be returned when user types a valid value.
## Options
* `:secret` - set to `true` if you want to make input invisible. Defaults to `false`.
* `:label` - a text label. Defaults to `nil` (no label).
* `:optional` - a boolean that sets whether value is optional. Defaults to `false`.
* `:cast` - casts a value after reading it from `stdio`. Defaults to `:string`. Possible values:
* an anonymous function with arity 1 that is described by `t:cast_input/0`
* a pair with built-in type represented as atom and a keyword-list with options. Built-in types:
* `:integer`, options:
* `:min` - a minimum allowed value. Defaults to `nil` (no lower bound).
* `:max` - a maximum allowed value. Defaults to `nil` (no upper bound).
* `:string`, options:
* no options
* an atom which is simply an alias to `{atom(), []}`
## Examples
Owl.IO.input()
#=> > hello world
"hello world"
Owl.IO.input(secret: true)
#=> >
"password"
Owl.IO.input(optional: true)
#=> >
nil
Owl.IO.input(label: "Your age", cast: {:integer, min: 18, max: 100})
#=> Your age
#=> > 12
#=> must be greater than or equal to 18
#=> Your age
#=> > 102
#=> must be less than or equal to 100
#=> Your age
#=> > 18
18
Owl.IO.input(label: "Birth date in ISO 8601 format:", cast: &Date.from_iso8601/1)
#=> Birth date in ISO 8601 format:
#=> > 1 January
#=> invalid_format
#=> Birth date in ISO 8601 format:
#=> > 2021-01-01
~D[2021-01-01]
"""
@spec input([input_option()]) :: any()
def input(opts \\ []) do
cast =
case Keyword.get(opts, :cast) || :string do
type_name when is_atom(type_name) ->
&cast_input(type_name, &1, [])
{type_name, opts} when is_atom(type_name) and is_list(opts) ->
&cast_input(type_name, &1, opts)
callback when is_function(callback, 1) ->
callback
end
label =
case Keyword.get(opts, :label) do
nil -> []
value -> [value, "\n"]
end
secret = Keyword.get(opts, :secret, false)
value = gets(secret, [label, Owl.Data.tag("> ", :blue)])
[&validate_required(&1, opts), cast]
|> Enum.reduce_while({:ok, value}, fn
callback, {:ok, value} ->
case callback.(value) do
{:ok, value} -> {:cont, {:ok, value}}
{:error, reason} -> {:halt, {:error, to_string(reason)}}
end
end)
|> case do
{:ok, value} ->
IO.puts([])
value
{:error, reason} ->
report_error(reason)
input(opts)
end
end
# https://github.com/hexpm/hex/blob/1523f44e8966d77a2c71738629912ad59627b870/lib/mix/hex/utils.ex#L32-L58
defp gets(true = _secret, prompt) do
[last_row | rest] = prompt |> Owl.Data.lines() |> Enum.reverse()
case rest do
[] -> :noop
rest -> puts(rest |> Enum.reverse() |> Owl.Data.unlines())
end
prompt = Owl.Data.to_ansidata(last_row)
pid = spawn_link(fn -> loop_prompt(prompt) end)
ref = make_ref()
value = IO.gets(prompt)
send(pid, {:done, self(), ref})
receive do: ({:done, ^pid, ^ref} -> :ok)
normalize_gets_result(value)
end
defp gets(false = _secret, prompt) do
prompt
|> Owl.Data.to_ansidata()
|> IO.gets()
|> normalize_gets_result()
end
defp normalize_gets_result(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
string -> string
end
end
defp normalize_gets_result(_) do
nil
end
defp loop_prompt(prompt) do
receive do
{:done, parent, ref} ->
send(parent, {:done, self(), ref})
IO.write(:standard_error, "\e[2K\r")
after
1 ->
IO.write(:standard_error, ["\e[2K\r", prompt])
loop_prompt(prompt)
end
end
defp report_error(text) do
Owl.IO.puts(Owl.Data.tag(text, :red))
end
defp validate_required(value, opts) do
optional? = Keyword.get(opts, :optional, false)
if is_nil(value) and not optional? do
{:error, "is required"}
else
{:ok, value}
end
end
defp cast_input(:integer, nil, _opts), do: {:ok, nil}
defp cast_input(:integer, binary, opts) do
case Integer.parse(binary) do
{number, ""} ->
with :ok <- validate_bounds(number, :min, opts[:min]),
:ok <- validate_bounds(number, :max, opts[:max]) do
{:ok, number}
end
_ ->
{:error, "not an integer"}
end
end
defp cast_input(:string, binary, _opts), do: {:ok, binary}
defp validate_bounds(_number, _, nil), do: :ok
defp validate_bounds(number, :max, limit) do
if number > limit do
{:error, "must be less than or equal to #{limit}"}
else
:ok
end
end
defp validate_bounds(number, :min, limit) do
if number < limit do
{:error, "must be greater than or equal to #{limit}"}
else
:ok
end
end
@doc """
Wrapper around `IO.puts/2` that accepts `t:Owl.Data.t/0`.
The other difference is that `device` argument is moved to second argument.
## Examples
Owl.IO.puts(["Hello ", Owl.Data.tag("world", :green)])
#=> Hello world
# specify Owl.LiveScreen as a device in order to print data above rendered live blocks
Owl.IO.puts(["Hello ", Owl.Data.tag("world", :green)], Owl.LiveScreen)
#=> Hello world
"""
@spec puts(Owl.Data.t(), device :: IO.device()) :: :ok
def puts(data, device \\ :stdio) do
data = Owl.Data.to_ansidata(data)
IO.puts(device, data)
end
@doc """
Wrapper around `IO.inspect/3` with changed defaults.
As in `puts/2`, `device` argument is moved to the end.
Options are the same as for `IO.inspect/3` with small changes:
* `:pretty` is `true` by default.
* `:syntax_colors` uses color schema from `IEx` by default.
* `:label` is extended and accepts `t:Owl.Data.t/0`.
## Examples
"Hello"
|> Owl.IO.inspect(label: "Greeting")
|> String.upcase()
|> Owl.IO.inspect(label: Owl.Data.tag("GREETING", :cyan))
#=> Greeting: "Hello"
#=> GREETING: "HELLO"
# inspect data above rendered live blocks
Owl.IO.inspect("Hello", [], Owl.LiveScreen)
#=> "Hello"
"""
@spec inspect(item, keyword(), IO.device()) :: item when item: var
def inspect(item, opts \\ [], device \\ :stdio) do
IO.inspect(
device,
item,
[
pretty: true,
syntax_colors: [
atom: :cyan,
string: :green,
list: :default_color,
boolean: :magenta,
nil: :magenta,
tuple: :default_color,
binary: :default_color,
map: :default_color,
reset: :yellow
]
]
|> Keyword.merge(opts)
|> Keyword.update(:label, nil, fn
nil -> nil
value -> Owl.Data.to_ansidata(value)
end)
)
end
@doc """
Returns a width of a terminal.
A wrapper around `:io.columns/0`, but returns `nil` if terminal is not found.
This is useful for convenient falling back to other value using `||/2` operator.
## Example
Owl.IO.columns() || 80
"""
@spec columns() :: pos_integer() | nil
def columns do
case :io.columns() do
{:ok, value} -> value
_ -> nil
end
end
end