lib/prompt.ex

# Prompt - library to help create interative CLI in Elixir
# Copyright (C) 2021  Matt Silbernagel
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

defmodule Prompt do
  @moduledoc """
  Prompt provides a complete solution for building interactive command line applications.

  It's very flexible and can be used just to provide helpers for taking input from the user and displaying output.

  ## Basic Usage
  `import Prompt` includes utilities for:
    * printing to the screen          -> `display/1`
    * printing tables to the screen   -> `table/1`
    * asking for confirmation         -> `confirm/1`
    * picking from a list of choices  -> `select/2`
    * asking for passwords            -> `password/1`
    * free form text input            -> `text/1`

  ## Advanced usage
  See `Prompt.Router`

  ## Building for Distribution

  There are a couple of different options for building a binary ready for distributing. Which ever approach you decide to use, you'll probably want to keep the docs instead of stripping them.
  For escripts, you'll add the following  to the escript key in mix.exs, if using Bakeware, you'll add it to the releases key.

  ```
  :strip_beams: [keep: ["Docs"]]
  ```

  ### Escript
  An [escript](https://hexdocs.pm/mix/master/Mix.Tasks.Escript.Build.html) is the most straightforward approach, but requires that erlang is already installed on the system.

  ### Bakeware
  This has been my preferred approach recently. [Bakeware](https://hexdocs.pm/bakeware/readme.html) uses releases to build a single executable binary that can be run on the system without the dependency on erlang or elixir.

  For Bakeware, I also set `export RELEASE_DISTRIBUTION=none` in `rel/env.sh.eex` and `rel/env.bat.eex` - unless you need erlang distribution in your CLI.

  For a complete example see [Slim](https://github.com/silbermm/slim_pickens)

  Run `MIX_ENV=prod mix release` to build the binary. This will output the usual release messages for a `mix release` command, but in the case of a CLI, it's a bit of a red herring. The binary you want to run as a CLI will be in `_build/prod/rel/bakeware/<your_app>`.


  ### Burrito
  [Burrito](https://github.com/burrito-elixir/burrito) is another option for building a single binary artifact. It is unique because you can build binaries for windows, mac and linux all from one machine.

  For a complete example see [Genex](https://github.com/silbermm/genex)

  Run `MIX_ENV=prod mix release` to build the binary. This will output the usual release messages for a `mix release` command, but in the case of a CLI, it's a bit of a red herring. The binary you want to run as a CLI will be in `burrito_out/<your_app>`.
  """

  import IO

  @typedoc """
  A keyword list of commands and implementations of `Prompt.Command`

  ## Examples

      iex> commands = [help: CLI.Commands.Help, version: CLI.Commands.Version]

  """
  @type command_list() :: keyword(Prompt.Command)

  @typedoc """
  The list of strings coming from the commmand-line arguments
  """
  @type argv() :: list(String.t())

  @colors Prompt.IO.Color.all()

  @confirm_options NimbleOptions.new!(
                     color: [
                       type: {:in, @colors},
                       doc: "The text color. One of `#{Kernel.inspect(@colors)}`."
                     ],
                     background_color: [
                       type: {:in, @colors},
                       doc: "The background color. One of `#{Kernel.inspect(@colors)}`."
                     ],
                     default_answer: [
                       type: {:in, [:yes, :no]},
                       default: :yes,
                       doc: "The default answer to the confirmation."
                     ],
                     mask_line: [
                       type: :boolean,
                       default: false,
                       doc:
                         "If set to true, this will mask the current line by replacing it with `#####`. Useful when showing passwords in the terminal."
                     ],
                     trim: [type: :boolean, default: true, doc: false],
                     from: [type: :atom, default: :confirm, doc: false]
                   )
  @doc section: :input
  @doc """
  Display a Y/n prompt.

  Sets 'Y' as the the default answer, allowing the user to just press the enter key. To make 'n' the default answer pass the option `default_answer: :no`

  Supported options:
  #{NimbleOptions.docs(@confirm_options)}

  ## Examples

      iex> Prompt.confirm("Send the email?")
      "Send the email? (Y/n):" Y
      iex> :yes

      iex> Prompt.confirm("Send the email?", default_answer: :no)
      "Send the email? (y/N):" [enter]
      iex> :no

  """
  @spec confirm(String.t(), keyword()) :: :yes | :no | :error
  def confirm(question, opts \\ []) do
    run(opts, @confirm_options, fn options ->
      Prompt.IO.Confirm.new(question, options)
    end)
  end

  @choice_options NimbleOptions.new!(
                    color: [
                      type: {:in, @colors},
                      doc: "The text color. One of `#{Kernel.inspect(@colors)}`."
                    ],
                    background_color: [
                      type: {:in, @colors},
                      doc: "The background color. One of `#{Kernel.inspect(@colors)}`."
                    ],
                    default_answer: [
                      type: :atom,
                      doc: "The default answer for the choices. Defaults to the first choice."
                    ],
                    trim: [type: :boolean, default: true, doc: false],
                    from: [type: :atom, default: :confirm, doc: false]
                  )

  @doc section: :input
  @doc """
  Display a choice prompt with custom answers.

  Takes a keyword list of answers in the form of atom to return and string to display.

  `[yes: "y", no: "n"]`

  will show "(y/n)" and return `:yes` or `:no` based on the choice.

  Supported options:
  #{NimbleOptions.docs(@choice_options)}

  ## Examples

      iex> Prompt.choice("Save password?",
      ...>   [yes: "y", no: "n", regenerate: "r"],
      ...>   default_answer: :regenerate
      ...> )
      "Save Password? (y/n/R):" [enter]
      iex> :regenerate
  """
  @spec choice(String.t(), keyword(), keyword()) :: :error | atom()
  def choice(question, custom, opts \\ []) do
    run(opts, @choice_options, fn options ->
      Prompt.IO.Choice.new(question, custom, options)
    end)
  end

  @text_options NimbleOptions.new!(
                  color: [
                    type: {:in, @colors},
                    doc:
                      "The text color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                  ],
                  background_color: [
                    type: {:in, @colors},
                    doc:
                      "The background color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                  ],
                  trim: [type: :boolean, default: false, doc: false],
                  min: [
                    type: :integer,
                    default: 0,
                    doc: "The minimum charactors required for input"
                  ],
                  max: [
                    type: :integer,
                    default: 0,
                    doc: "The maximum charactors required for input"
                  ]
                )

  @doc section: :input
  @doc """
  Display text on the screen and wait for the users text imput.

  Supported options:
  #{NimbleOptions.docs(@text_options)}

  ## Examples

      iex> Prompt.text("Enter your email")
      "Enter your email:" t@t.com
      iex> t@t.com
  """
  @spec text(String.t(), keyword()) :: String.t() | :error | :error_min | :error_max
  def text(display, opts \\ []) do
    run(opts, @text_options, fn options ->
      Prompt.IO.Text.new(display, options)
    end)
  end

  @select_options NimbleOptions.new!(
                    color: [
                      type: {:in, @colors},
                      doc:
                        "The text color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                    ],
                    background_color: [
                      type: {:in, @colors},
                      doc:
                        "The background color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                    ],
                    multi: [
                      type: :boolean,
                      default: false,
                      doc: "Allows multiple selections from the options presented."
                    ],
                    trim: [type: :boolean, default: true, doc: false]
                  )

  @doc section: :input
  @doc """
  Displays options to the user denoted by numbers.

  Allows for a list of 2 tuples where the first value is what is displayed
  and the second value is what is returned to the caller.

  Supported options:
  #{NimbleOptions.docs(@select_options)}

  ## Examples

      iex> Prompt.select("Choose One", ["Choice A", "Choice B"])
      "  [1] Choice A"
      "  [2] Choice B"
      "Choose One [1-2]:" 1
      iex> "Choice A"

      iex> Prompt.select("Choose One", [{"Choice A", 1000}, {"Choice B", 1001}])
      "  [1] Choice A"
      "  [2] Choice B"
      "Choose One [1-2]:" 2
      iex> 1001

      iex> Prompt.select("Choose as many as you want", ["Choice A", "Choice B"], multi: true)
      "  [1] Choice A"
      "  [2] Choice B"
      "Choose as many as you want [1-2]:" 1 2
      iex> ["Choice A", "Choice B"]

  """
  @spec select(String.t(), list(String.t()) | list({String.t(), any()}), keyword()) ::
          any() | :error
  def select(display, choices, opts \\ []) do
    run(opts, @select_options, fn options ->
      Prompt.IO.Select.new(display, choices, options)
    end)
  end

  @password_options NimbleOptions.new!(
                      color: [
                        type: {:in, @colors},
                        doc:
                          "The text color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                      ],
                      background_color: [
                        type: {:in, @colors},
                        doc:
                          "The background color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                      ]
                    )

  @doc section: :input
  @doc """
  Prompt the user for input, but conceal the users typing.

  Supported options:
  #{NimbleOptions.docs(@password_options)}

  ## Examples

      iex> Prompt.password("Enter your passsword")
      "Enter your password:"
      iex> "super_secret_passphrase"
  """
  @spec password(String.t(), keyword()) :: String.t()
  def password(display, opts \\ []) do
    run(opts, @password_options, fn options ->
      Prompt.IO.Password.new(display, options)
    end)
  end

  @display_options NimbleOptions.new!(
                     color: [
                       type: {:in, @colors},
                       doc:
                         "The text color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                     ],
                     background_color: [
                       type: {:in, @colors},
                       doc:
                         "The background color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                     ],
                     trim: [type: :boolean, default: false, doc: false],
                     from: [type: :atom, default: :self, doc: false],
                     position: [
                       type: {:in, [:left, :right]},
                       default: :left,
                       doc:
                         "Print the content starting from the leftmost position or the rightmost position"
                     ],
                     mask_line: [
                       type: :boolean,
                       default: false,
                       doc:
                         "If set to true, this will mask the current line by replacing it with `#####`. Useful when showing passwords in the terminal."
                     ]
                   )

  @doc section: :output
  @doc """
  Writes text to the screen.

  Takes a single string argument or a list of strings where each item in the list will be diplayed on a new line.


  Supported options:
  #{NimbleOptions.docs(@display_options)}

  ## Examples

      iex> Prompt.display("Hello from the terminal!")
      "Hello from the terminal!"

      iex> Prompt.display(["Hello", "from", "the", "terminal"])
      "Hello"
      "from"
      "the"
      "terminal"
  """
  @spec display(String.t() | list(String.t()), keyword()) :: :ok
  def display(text, opts \\ []), do: _display(text, opts)

  defp _display(texts, opts) when is_list(texts) do
    _ = Enum.map(texts, &display(&1, opts))
    :ok
  end

  defp _display(text, opts) do
    run(opts, @display_options, fn options ->
      Prompt.IO.Display.new(text, options)
    end)
  end

  @table_options NimbleOptions.new!(
                   header: [
                     type: :boolean,
                     default: false,
                     doc: "Use the first element as the header for the table."
                   ],
                   border: [
                     type: {:in, [:normal, :markdown, :none]},
                     default: :normal,
                     doc:
                       "Determine how the border is displayed, one of :normal (default), :markdown, or :none"
                   ],
                   color: [
                     type: {:in, @colors},
                     doc:
                       "The text color. One of `#{Kernel.inspect(@colors)}`. Defaults to the terminal default."
                   ]
                 )

  @doc section: :output
  @doc """
  Print an ASCII table of data. Requires a list of lists as input.

  Supported options:
  #{NimbleOptions.docs(@table_options)}

  ## Examples

      iex> Prompt.table([["Hello", "from", "the", "terminal!"],["this", "is", "another", "row"]])
      "
       +-------+------+---------+----------+
       | Hello | from | the     | terminal |
       | this  | is   | another | row      |
       +-------+------+---------+----------+
      "

      iex> Prompt.table([["One", "Two", "Three", "Four"], ["Hello", "from", "the", "terminal!"],["this", "is", "another", "row"]], header: true)
      "
       +-------+------+---------+----------+
       | One   | Two  | Three   | Four     |
       +-------+------+---------+----------+
       | Hello | from | the     | terminal |
       | this  | is   | another | row      |
       +-------+------+---------+----------+
      "

      iex> Prompt.table([["One", "Two", "Three", "Four"], ["Hello", "from", "the", "terminal!"],["this", "is", "another", "row"]], header: true, border: :markdown)
      "
       | One   | Two  | Three   | Four     |
       |-------|------|---------|----------|
       | Hello | from | the     | terminal |
       | this  | is   | another | row      |
      "

      iex> Prompt.table([["Hello", "from", "the", "terminal!"],["this", "is", "another", "row"]], border: :none)
      "
       Hello from the     terminal
       this  is   another row
      "

  """
  @spec table(list(list()), keyword()) :: :ok
  def table(matrix, opts \\ []) when is_list(matrix) do
    case NimbleOptions.validate(opts, @table_options) do
      {:ok, options} ->
        table = matrix |> build_table(options)
        color = Keyword.get(options, :color, IO.ANSI.default_color())

        [
          :reset,
          IO.ANSI.default_background(),
          color,
          table,
          :reset
        ]
        |> IO.ANSI.format()
        |> write()

      {:error, err} ->
        display(err.message, error: true)
        :error
    end
  end

  @doc """
  Use this to get an iolist back of the table. Useful when you want an ascii `table/1` for
  other mediums like markdown files.
  """
  @spec table_data(list(list()), keyword()) :: [<<>> | [any()], ...]
  def table_data(matrix, opts \\ [border: :normal]) when is_list(matrix) do
    matrix
    |> build_table(opts)
  end

  defp build_table(matrix, opts) do
    tbl = Prompt.Table.new(matrix, opts)
    row_delimiter = Prompt.Table.row_delimiter(tbl)

    first =
      if Keyword.get(opts, :border) == :normal do
        row_delimiter
      else
        ""
      end

    {next, matrix} =
      if Keyword.get(opts, :header, false) do
        # get the first 'row'
        headers = Enum.at(matrix, 0)
        {[Prompt.Table.row(tbl, headers), row_delimiter], Enum.drop(matrix, 1)}
      else
        {"", matrix}
      end

    rest =
      for row <- matrix do
        Prompt.Table.row(tbl, row)
      end

    last =
      if Keyword.get(opts, :border) == :normal do
        row_delimiter
      else
        ""
      end

    if Keyword.get(opts, :color) do
      [first, next, rest, last]
    else
      [first, next, rest, last]
    end
  end

  defp run(opts, validation, io) do
    case NimbleOptions.validate(opts, validation) do
      {:ok, options} ->
        io.(options)
        |> Prompt.IO.display()
        |> Prompt.IO.evaluate()

      {:error, err} ->
        display(err.message, error: true)
        :error
    end
  end
end