Packages

A library for writing interactive CLIs.

Current section

Files

Jump to
marcus lib marcus.ex
Raw

lib/marcus.ex

defmodule Marcus do
@moduledoc """
A library for writing interactive CLIs.
## Features
Marcus provides helpers for:
* printing ANSI-formatted information,
* printing information in green,
* printing errors (in bright red, on `stderr`)
* halting the VM with an error message and status.
You can also prompt the user for:
* a string,
* an integer,
* a yes/no question,
* a choice from a list.
## Examples
import Marcus
prompt_string("Name")
# Name: Jean-Philippe
# => "Jean-Philippe"
prompt_integer("Integer")
# Integer: 8
# => 8
yes?("Do you want?")
# Do you want? (y/n) y
# => true
choose("Make a choice:", item1: "Item 1", item2: "Item 2")
# Make a choice:
#
# 1. Item 1
# 2. Item 2
#
# Choice: 2
# => :item2
"""
alias IO.ANSI
@typedoc "Answer to a yes/no question"
@type yesno() :: :yes | :no | nil
@yes ~w(y Y yes YES Yes)
@no ~w(n N no NO No)
@doc """
Prints the given ANSI-formatted `message`.
"""
@spec info(ANSI.ansidata()) :: :ok
def info(message) do
message |> ANSI.format() |> IO.puts()
end
@doc """
Prints the given ANSI-formatted `message` in green.
"""
@spec green_info(ANSI.ansidata()) :: :ok
def green_info(message) do
info([:green, message])
end
@doc """
Prints the given ANSI-formatted error `message` on `:stderr`.
"""
@spec error(ANSI.ansidata()) :: :ok
def error(message) do
IO.puts(:stderr, ANSI.format([:red, :bright, message]))
end
@doc """
Prints the given ANSI-formatter error an exits with an error status.
"""
@spec halt(ANSI.ansidata()) :: no_return()
@spec halt(ANSI.ansidata(), non_neg_integer()) :: no_return()
def halt(message, status \\ 1) do
error(message)
System.halt(status)
end
@doc """
Prints the given `message` and prompts the user for input.
The result string is trimmed.
## Options
* `default` - default value for empty replies (printed in the prompt if set)
* `required` - wether a non-empty input is required (default: `false`)
* `error_message` - the message to print if a required input is missing
* `length` - the range of acceptable string length
## Examples
prompt_string("GitHub account")
# Name: ejpcmac
# => "ejpcmac"
prompt_string("Hello", default: "world")
# Hello [world]:
# => "world"
prompt_string("Name", required: true)
# Name:
# You must provide a value!
prompt_string("Name", required: true, error_message: "Please provide a name.")
# Name:
# Please provide a name.
prompt_string("Nick", length: 3..20)
# Nick (3-20 characters): me
# The value must be 3 to 20 characters
#
# Nick (3-20 characters): my_nick
# => "my_nick"
"""
@spec prompt_string(String.t()) :: String.t()
@spec prompt_string(String.t(), keyword()) :: String.t()
def prompt_string(message, opts \\ []) do
(message <> format_length(opts[:length]) <> format_default(opts[:default]))
|> IO.gets()
|> String.trim()
|> parse_response(opts[:default], !!opts[:required])
|> case do
nil ->
error_message = opts[:error_message] || "You must provide a value!"
error(error_message <> "\n")
prompt_string(message, opts)
value ->
if valid_length?(value, opts[:length]) do
value
else
min..max = opts[:length]
error("The value must be #{min} to #{max} characters long.\n")
prompt_string(message, opts)
end
end
end
@spec format_length(Range.t() | nil) :: String.t()
defp format_length(nil), do: ""
defp format_length(min..max), do: " (#{min}-#{max} characters)"
@spec format_default(String.t() | nil) :: String.t()
defp format_default(nil), do: ": "
defp format_default(default), do: " [#{default}]: "
@spec parse_response(String.t(), String.t() | nil, boolean()) ::
String.t() | nil
defp parse_response("", nil, true), do: nil
defp parse_response("", default, _) when not is_nil(default), do: default
defp parse_response(value, _default, _), do: value
@spec valid_length?(String.t(), Range.t() | nil) :: boolean()
defp valid_length?(_value, nil), do: true
defp valid_length?(value, min..max), do: String.length(value) in min..max
@doc """
Prints the given `message` and prompts the user for an integer.
## Options
* `default` - default value for empty replies (printed in the prompt if set)
* `range` - the acceptable range
## Examples
prompt_integer("Age")
# Age: 24
# => 24
prompt_integer("Integer")
# Integer: Hello
# The value must be an integer.
prompt_integer("Current level", default: 1)
# Current level [1]:
# => 1
prompt_integer("Percentage", range: 0..100)
# Percentage (0-100): 200
# The value must be between 0 and 100.
"""
@spec prompt_integer(String.t()) :: integer()
@spec prompt_integer(String.t(), keyword()) :: integer()
def prompt_integer(message, opts \\ []) do
default = if opts[:default], do: opts[:default] |> Integer.to_string()
(message <> format_range(opts[:range]))
|> prompt_string(default: default, required: true)
|> Integer.parse()
|> case do
{choice, ""} ->
if in_range?(choice, opts[:range]) do
choice
else
min..max = opts[:range]
error("The value must be between #{min} and #{max}.\n")
prompt_integer(message, opts)
end
_ ->
error("The value must be an integer.\n")
prompt_integer(message, opts)
end
end
@spec format_range(Range.t() | nil) :: String.t()
defp format_range(nil), do: ""
defp format_range(min..max), do: " (#{min}-#{max})"
@spec in_range?(integer(), Range.t() | nil) :: boolean()
defp in_range?(_value, nil), do: true
defp in_range?(value, min..max), do: value in min..max
@doc """
Asks the user a yes/no `question`.
If there is no default value, the user must type an answer. Otherwise hitting
enter chooses the default answer.
## Options
* `default` - default value for empty replies (`:yes` or `:no`, hilighted in
the prompt if set)
## Examples
yes?("Continue?")
# Continue? (y/n)
# You must answer yes or no.
#
# Continue? (y/n) y
# => true
yes?("Is it good?", default: :yes)
# Is it good? [Y/n]
# => true
yes?("No?", default: :no)
# No? [y/N]
# => false
"""
@spec yes?(String.t()) :: boolean()
@spec yes?(String.t(), keyword()) :: boolean()
def yes?(message, opts \\ []) do
(message <> format_yesno(opts[:default]))
|> IO.gets()
|> String.trim()
|> parse_yesno(opts[:default])
|> case do
nil ->
error("You must answer yes or no.\n")
yes?(message, opts)
answer ->
answer == :yes
end
end
@spec format_yesno(yesno()) :: String.t()
defp format_yesno(:yes), do: " [Y/n] "
defp format_yesno(:no), do: " [y/N] "
defp format_yesno(nil), do: " (y/n) "
@spec parse_yesno(String.t(), yesno()) :: yesno()
defp parse_yesno(value, _default) when value in @yes, do: :yes
defp parse_yesno(value, _default) when value in @no, do: :no
defp parse_yesno("", default), do: default
defp parse_yesno(_, _default), do: nil
@doc """
Asks the user to choose between a list of elements.
The given `list` must be a keyword list. The values will be printed and the
key of the chosen one returned.
## Options
* `default` - default key for empty replies (printed in the prompt if set)
## Examples
choose("What do you want?",
tea: "A cup of tea",
coffee: "Some coffee",
other: "Something else"
)
# What do you want?
#
# 1. A cup of tea
# 2. Some coffee
# 3. Something else
#
# Choice: 4
# The choice must be an integer between 1 and 3.
#
# Choice: 3
# => :other
choose("Please make a choice:", [good: "The good choice"], default: :good)
# Please make a choice:
#
# 1. The good choice
#
# Choice [1]:
# => :good
"""
@spec choose(String.t(), keyword()) :: atom()
@spec choose(String.t(), keyword(), keyword()) :: atom()
def choose(message, [_ | _] = list, opts \\ []) do
info(message <> "\n")
list
|> Keyword.values()
|> Enum.with_index(1)
|> Enum.each(fn {elem, i} -> IO.puts(" #{i}. #{elem}") end)
info("")
default_index =
with v when not is_nil(v) <- opts[:default],
i when not is_nil(i) <- Enum.find_index(list, &(elem(&1, 0) == v)),
do: Integer.to_string(i + 1)
index = list |> length() |> get_choice(default_index)
list
|> Enum.at(index - 1)
|> elem(0)
end
@spec get_choice(pos_integer(), pos_integer() | nil) :: pos_integer()
defp get_choice(max, default) do
"Choice"
|> prompt_string(
default: default,
required: true,
error_message: "You must make a choice!"
)
|> Integer.parse()
|> case do
{choice, ""} when choice in 1..max ->
choice
_ ->
error("The choice must be an integer between 1 and #{max}.\n")
get_choice(max, default)
end
end
end