diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..c966f58 --- /dev/null +++ b/.credo.exs @@ -0,0 +1,162 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any exec using `mix credo -C `. If no exec name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: ["lib/", "src/", "test/", "web/", "apps/"], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: true, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames}, + {Credo.Check.Consistency.LineEndings}, + {Credo.Check.Consistency.ParameterPatternMatching}, + {Credo.Check.Consistency.SpaceAroundOperators}, + {Credo.Check.Consistency.SpaceInParentheses}, + {Credo.Check.Consistency.TabsOrSpaces}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, priority: :low}, + # For some checks, you can also set other parameters + # + # If you don't want the `setup` and `test` macro calls in ExUnit tests + # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just + # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. + # + {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, exit_status: 0}, + {Credo.Check.Design.TagFIXME}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder}, + {Credo.Check.Readability.FunctionNames}, + {Credo.Check.Readability.LargeNumbers}, + {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80}, + {Credo.Check.Readability.ModuleAttributeNames}, + {Credo.Check.Readability.ModuleDoc}, + {Credo.Check.Readability.ModuleNames}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, + {Credo.Check.Readability.ParenthesesInCondition}, + {Credo.Check.Readability.PredicateFunctionNames}, + {Credo.Check.Readability.PreferImplicitTry}, + {Credo.Check.Readability.RedundantBlankLines}, + {Credo.Check.Readability.StringSigils}, + {Credo.Check.Readability.TrailingBlankLine}, + {Credo.Check.Readability.TrailingWhiteSpace}, + {Credo.Check.Readability.VariableNames}, + {Credo.Check.Readability.Semicolons}, + {Credo.Check.Readability.SpaceAfterCommas}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.DoubleBooleanNegation, false}, + {Credo.Check.Refactor.CondStatements}, + {Credo.Check.Refactor.CyclomaticComplexity}, + {Credo.Check.Refactor.FunctionArity}, + {Credo.Check.Refactor.LongQuoteBlocks}, + {Credo.Check.Refactor.MapInto}, + {Credo.Check.Refactor.MatchInCondition}, + {Credo.Check.Refactor.NegatedConditionsInUnless}, + {Credo.Check.Refactor.NegatedConditionsWithElse}, + {Credo.Check.Refactor.Nesting}, + {Credo.Check.Refactor.PipeChainStart, + excluded_argument_types: [:atom, :binary, :fn, :keyword], + excluded_functions: []}, + {Credo.Check.Refactor.UnlessWithElse}, + + # + ## Warnings + # + {Credo.Check.Warning.BoolOperationOnSameValues}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck}, + {Credo.Check.Warning.IExPry}, + {Credo.Check.Warning.IoInspect}, + {Credo.Check.Warning.LazyLogging}, + {Credo.Check.Warning.OperationOnSameValues}, + {Credo.Check.Warning.OperationWithConstantResult}, + {Credo.Check.Warning.UnusedEnumOperation}, + {Credo.Check.Warning.UnusedFileOperation}, + {Credo.Check.Warning.UnusedKeywordOperation}, + {Credo.Check.Warning.UnusedListOperation}, + {Credo.Check.Warning.UnusedPathOperation}, + {Credo.Check.Warning.UnusedRegexOperation}, + {Credo.Check.Warning.UnusedStringOperation}, + {Credo.Check.Warning.UnusedTupleOperation}, + {Credo.Check.Warning.RaiseInsideRescue}, + + # + # Controversial and experimental checks (opt-in, just remove `, false`) + # + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, false}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.MapGetUnsafePass, false}, + {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, + + # + # Deprecated checks (these will be deleted after a grace period) + # + {Credo.Check.Readability.Specs, false} + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + ] +} diff --git a/.dialyzer_ignore b/.dialyzer_ignore new file mode 100644 index 0000000..e69de29 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..865b01e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +max_line_length = 80 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{md,sh}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..6d28500 --- /dev/null +++ b/.envrc @@ -0,0 +1,84 @@ +#################################### +# Environment setup for Nix shells # +#################################### + +# From https://github.com/direnv/direnv/wiki/Nix#persistent-cached-shell +# +# Usage: use_nix [...] +# +# Load environment variables from `nix-shell`. +# If you have a `default.nix` or `shell.nix` one of these will be used and +# the derived environment will be stored at ./.direnv/env- +# and symlink to it will be created at ./.direnv/default. +# Dependencies are added to the GC roots, such that the environment remains persistent. +# +# Packages can also be specified directly via e.g `use nix -p ocaml`, +# however those will not be added to the GC roots. +# +# The resulting environment is cached for better performance. +# +# To trigger switch to a different environment: +# `rm -f .direnv/default` +# +# To derive a new environment: +# `rm -rf .direnv/env-$(md5sum {shell,default}.nix 2> /dev/null | cut -c -32)` +# +# To remove cache: +# `rm -f .direnv/dump-*` +# +# To remove all environments: +# `rm -rf .direnv/env-*` +# +# To remove only old environments: +# `find .direnv -name 'env-*' -and -not -name `readlink .direnv/default` -exec rm -rf {} +` +# +use_nix() { + set -e + + local shell="shell.nix" + if [[ ! -f "${shell}" ]]; then + shell="default.nix" + fi + + if [[ ! -f "${shell}" ]]; then + fail "use nix: shell.nix or default.nix not found in the folder" + fi + + local dir="${PWD}"/.direnv + local default="${dir}/default" + if [[ ! -L "${default}" ]] || [[ ! -d `readlink "${default}"` ]]; then + local wd="${dir}/env-`md5sum "${shell}" | cut -c -32`" # TODO: Hash also the nixpkgs version? + mkdir -p "${wd}" + + local drv="${wd}/env.drv" + if [[ ! -f "${drv}" ]]; then + log_status "use nix: deriving new environment" + IN_NIX_SHELL=1 nix-instantiate --add-root "${drv}" --indirect "${shell}" > /dev/null + nix-store -r `nix-store --query --references "${drv}"` --add-root "${wd}/dep" --indirect > /dev/null + fi + + rm -f "${default}" + ln -s `basename "${wd}"` "${default}" + fi + + local drv=`readlink -f "${default}/env.drv"` + local dump="${dir}/dump-`md5sum ".envrc" | cut -c -32`-`md5sum ${drv} | cut -c -32`" + + if [[ ! -f "${dump}" ]] || [[ "${XDG_CONFIG_DIR}/direnv/direnvrc" -nt "${dump}" ]]; then + log_status "use nix: updating cache" + + old=`find "${dir}" -name 'dump-*'` + nix-shell "${drv}" --show-trace "$@" --run 'direnv dump' > "${dump}" + rm -f ${old} + fi + + direnv_load cat "${dump}" + + watch_file "${default}" + watch_file shell.nix + if [[ ${shell} == "default.nix" ]]; then + watch_file default.nix + fi +} + +use nix diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..4f5212b --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,7 @@ +[ + inputs: [ + "{mix,.iex,.formatter,.credo}.exs", + "{config,lib,test}/**/*.{ex,exs}" + ], + line_length: 80 +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eaa65a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +## +## Application artifacts +## + +# direnv cache for Nix shells +/.direnv/ + +# Elixir build directory +/_build/ + +# Elixir dependencies +/deps/ +/.fetch + +# Elixir binary files +*.beam +*.ez +/marcus-*.tar +/marcus + +# Test coverage and documentation +/cover/ +/doc/ + +## +## Editor artifacts +## + +/.elixir_ls/ +/.history/ + +## +## Crash dumps +## + +# Erang VM +erl_crash.dump diff --git a/.gitsetup b/.gitsetup new file mode 100755 index 0000000..d4e5b16 --- /dev/null +++ b/.gitsetup @@ -0,0 +1,11 @@ +#!/bin/sh + +set -e +set -x + +# Setup git-flow +git flow init -d +git config gitflow.prefix.versiontag "v" +git config gitflow.feature.finish.no-ff true +git config gitflow.release.finish.sign true +git config gitflow.hotfix.finish.sign true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0129d61 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## v0.1.0 + +* Initial version extracted from xgen diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7995d26 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,112 @@ +# Contributing to Marcus + +This project uses [git-flow](https://github.com/petervanderdoes/gitflow-avh). +The `master` branch is reserved to releases: the development process occurs on +`develop` and feature branches. **Please never commit to master.** + +## Setup + +### Local repository + +1. Fork the repository + +2. Clone your fork to a local repository: + + $ git clone https://github.com/you/marcus.git + $ cd marcus + +3. Add the main repository as a remote: + + $ git remote add upstream https://github.com/ejpcmac/marcus.git + +4. Setup `git-flow`: + + $ ./.gitsetup + +You should now be on `develop`. + +### Development environment + +1. Install an Elixir environment. + +2. Fetch the project dependencies and build the project: + + $ cd marcus + $ mix do deps.get, compile + +3. Launch the tests: + + $ mix test + +All the tests should pass. + +## Workflow + +To make a change, please follow this workflow: + +1. Checkout to `develop` and apply the last upstream changes (use rebase, not + merge!): + + $ git checkout develop + $ git fetch --all --prune + $ git rebase upstream/develop + +2. Create a new branch with an explicit name: + + $ git checkout -b + + Alternatively, if you are working on a feature which would need more work, + you can create a feature branch with `git-flow`: + + $ git flow feature start + + *Note: always open an issue and ask before starting a big feature, to avoid + it not beeing merged and your time lost.* + +3. Work on your feature (don’t forget to write typespecs and tests; you can + check your coverage with `mix coveralls.html` and open + `cover/excoveralls.html`): + + # Some work + $ git commit -am "My first change" + # Some work + $ git commit -am "My second change" + ... + +4. When your feature is ready, feel free to use + [interactive rebase](https://help.github.com/articles/about-git-rebase/) so + your history looks clean and is easy to follow. Then, apply the last + upstream changes on `develop` to prepare integration: + + $ git checkout develop + $ git fetch --all --prune + $ git rebase upstream/develop + +5. If there were commits on `develop` since the beginning of your feature + branch, integrate them by **rebasing** if your branch has few commits, or + merging if you had a long-lived branch: + + $ git checkout + $ git rebase develop + + *Note: the only case you should merge is when you are working on a big + feature. If it is the case, we should have discussed this before as stated + above.* + +6. Run the tests and static analyzers to ensure there is no regression and all + works as expected: + + $ mix test + $ mix dialyzer + $ mix credo + +7. If it’s all good, open a pull request to merge your branch into the `develop` + branch on the main repository. + +## Coding style + +Please format your code with `mix format` or your editor and follow +[this style guide](https://github.com/christopheradams/elixir_style_guide). + +All contributed code must be documented and functions must have typespecs. In +general, take your inspiration in the existing code. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63b706a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Jean-Philippe Cugnet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c30d80e --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Marcus + +[![hex.pm version](http://img.shields.io/hexpm/v/marcus.svg?style=flat)](https://hex.pm/packages/marcus) + +Marcus is a library for writing interactive CLIs in Elixir. + +## 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 + +```elixir +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 +``` + +## Setup + +To use Marcus in your project, add this to your Mix dependencies: + +```elixir +{:marcus, "~> 0.1.0"} +``` + +## [Contributing](CONTRIBUTING.md) + +Before contributing to this project, please read the +[CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +Copyright © 2018 Jean-Philippe Cugnet + +This project is licensed under the [MIT license](LICENSE). diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..320735f --- /dev/null +++ b/config/config.exs @@ -0,0 +1,16 @@ +use Mix.Config + +# This configuration is loaded before any dependency and is restricted to this +# project. If another project depends on this project, this file won’t be loaded +# nor affect the parent project. For this reason, if you want to provide default +# values for your application for 3rd-party users, it should be done in your +# "mix.exs" file. + +if Mix.env() == :dev do + # Clear the console before each test run + config :mix_test_watch, clear: true +end + +# # Import environment specific config. This must remain at the bottom of this +# # file so it overrides the configuration defined above. +# import_config "#{Mix.env()}.exs" diff --git a/lib/marcus.ex b/lib/marcus.ex new file mode 100644 index 0000000..caa995d --- /dev/null +++ b/lib/marcus.ex @@ -0,0 +1,361 @@ +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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..e5f5042 --- /dev/null +++ b/mix.exs @@ -0,0 +1,78 @@ +defmodule Marcus.MixProject do + use Mix.Project + + @version "0.1.0" + @repo_url "https://github.com/ejpcmac/marcus" + + def project do + [ + app: :marcus, + version: @version, + elixir: "~> 1.4", + start_permanent: Mix.env() == :prod, + deps: deps(), + + # Tools + dialyzer: dialyzer(), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: cli_env(), + + # Docs + docs: [ + main: "Marcus", + source_url: @repo_url, + source_ref: "v#{@version}" + ], + + # Package + package: package(), + description: "A library for writing interactive CLIs." + ] + end + + defp deps do + [ + # Development and test dependencies + {:credo, "~> 0.10.0", only: :dev, runtime: false}, + {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, + {:excoveralls, ">= 0.0.0", only: :test, runtime: false}, + {:mix_test_watch, ">= 0.0.0", only: :dev, runtime: false}, + {:ex_unit_notifier, ">= 0.0.0", only: :test, runtime: false}, + {:stream_data, "~> 0.4.0", only: :test}, + + # Project dependencies + + # Documentation dependencies + {:ex_doc, "~> 0.19", only: :dev, runtime: false} + ] + end + + # Dialyzer configuration + defp dialyzer do + [ + plt_add_deps: :transitive, + flags: [ + :unmatched_returns, + :error_handling, + :race_conditions + ], + ignore_warnings: ".dialyzer_ignore" + ] + end + + defp cli_env do + [ + # Always run coveralls mix tasks in `:test` env. + coveralls: :test, + "coveralls.detail": :test, + "coveralls.html": :test + ] + end + + defp package do + [ + licenses: ["MIT"], + links: %{"GitHub" => @repo_url} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..04261c6 --- /dev/null +++ b/mix.lock @@ -0,0 +1,24 @@ +%{ + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, + "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "credo": {:hex, :credo, "0.10.2", "03ad3a1eff79a16664ed42fc2975b5e5d0ce243d69318060c626c34720a49512", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, + "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, + "earmark": {:hex, :earmark, "1.2.6", "b6da42b3831458d3ecc57314dff3051b080b9b2be88c2e5aa41cd642a5b044ed", [:mix], [], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm"}, + "excoveralls": {:hex, :excoveralls, "0.10.1", "407d50ac8fc63dfee9175ccb4548e6c5512b5052afa63eedb9cd452a32a91495", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, + "file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"}, + "hackney": {:hex, :hackney, "1.14.3", "b5f6f5dcc4f1fba340762738759209e21914516df6be440d85772542d4a5e412", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "jason": {:hex, :jason, "1.1.1", "d3ccb840dfb06f2f90a6d335b536dd074db748b3e7f5b11ab61d239506585eb2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, + "mix_test_watch": {:hex, :mix_test_watch, "0.9.0", "c72132a6071261893518fa08e121e911c9358713f62794a90c95db59042af375", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, + "stream_data": {:hex, :stream_data, "0.4.2", "fa86b78c88ec4eaa482c0891350fcc23f19a79059a687760ddcf8680aac2799b", [:mix], [], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..f63279d --- /dev/null +++ b/shell.nix @@ -0,0 +1,22 @@ +{ pkgs ? import {} }: + +with pkgs; + +let + inherit (lib) optional optionals; + + elixir = beam.packages.erlangR21.elixir_1_7; + gitflow = gitAndTools.gitflow; +in + +mkShell { + buildInputs = [ elixir git gitflow ] + ++ optional stdenv.isLinux libnotify # For ExUnit Notifier on Linux. + ++ optional stdenv.isLinux inotify-tools # For file_system on Linux. + ++ optional stdenv.isDarwin terminal-notifier # For ExUnit Notifier on macOS. + ++ optionals stdenv.isDarwin (with darwin.apple_sdk.frameworks; [ + # For file_system on macOS. + CoreFoundation + CoreServices + ]); +} diff --git a/test/marcus_test.exs b/test/marcus_test.exs new file mode 100644 index 0000000..b46eb3c --- /dev/null +++ b/test/marcus_test.exs @@ -0,0 +1,450 @@ +defmodule MarcusTest do + use ExUnit.Case + use ExUnitProperties + + import ExUnit.CaptureIO + import Marcus + + alias IO.ANSI + + describe "info/1" do + property "prints the given message" do + check all message <- string(:printable) do + assert capture_io(fn -> info(message) end) == message <> "\n" + end + end + + property "formats the message" do + check all message <- string(:printable) do + assert capture_io(fn -> info([:red, message]) end) == + ANSI.red() <> message <> ANSI.reset() <> "\n" + end + end + end + + describe "green_info/1" do + property "prints the given message in green" do + check all message <- string(:printable) do + assert capture_io(fn -> green_info(message) end) == + ANSI.green() <> message <> ANSI.reset() <> "\n" + end + end + end + + describe "error/1" do + property "prints the given message in bright red on stderr" do + check all message <- string(:printable) do + assert capture_io(:stderr, fn -> error(message) end) == + ANSI.red() <> ANSI.bright() <> message <> ANSI.reset() <> "\n" + end + end + + property "formats the message" do + check all message <- string(:printable) do + assert capture_io(:stderr, fn -> error([:blue, message]) end) == + ANSI.red() <> + ANSI.bright() <> + ANSI.blue() <> message <> ANSI.reset() <> "\n" + end + end + end + + describe "halt/2" do + # Non-testable (calls to System.halt/1) + end + + describe "prompt_string/2" do + ## Standart cases + + property "prints the given message and prompts for an input" do + check all message <- string(:printable) do + assert capture_io("\n", fn -> prompt_string(message) end) == + message <> ": " + end + end + + property "adds the default value to the prompt" do + check all message <- string(:printable), + default <- string(:printable) do + assert capture_io("\n", fn -> + prompt_string(message, default: default) + end) == "#{message} [#{default}]: " + end + end + + property "adds the length range to the prompt" do + check all message <- string(:printable), + min <- integer(1..50), + max <- integer((min + 1)..100), + input <- string(:alphanumeric, length: min) do + assert capture_io(input <> "\n", fn -> + prompt_string(message, length: min..max) + end) == "#{message} (#{min}-#{max} characters): " + end + end + + property "returns the user input" do + check all input <- string(:printable, min_length: 1) do + capture_io(input <> "\n", fn -> + assert prompt_string("") == input + end) + end + end + + test "accepts empty inputs by default" do + capture_io("\n", fn -> + assert prompt_string("") == "" + end) + end + + property "returns the default value on empty inputs" do + check all default <- string(:printable) do + capture_io("\n", fn -> + assert prompt_string("", default: default) == default + end) + end + end + + property "returns the user input if it is not empty when there is a default + value" do + check all default <- string(:printable), + input <- string(:printable, min_length: 1) do + capture_io(input <> "\n", fn -> + assert prompt_string("", default: default) == input + end) + end + end + + ## Errors + + test "prints an error message and keep asking on empty inputs when + `required: true` is set" do + capture_io("\n.\n", fn -> + assert capture_io(:stderr, fn -> + prompt_string("", required: true) + end) == + ANSI.red() <> + ANSI.bright() <> + "You must provide a value!\n" <> ANSI.reset() <> "\n" + end) + end + + property "prints a custom error message if set" do + check all error_message <- string(:printable) do + capture_io("\n.\n", fn -> + assert capture_io(:stderr, fn -> + prompt_string("", + required: true, + error_message: error_message + ) + end) == + ANSI.red() <> + ANSI.bright() <> + error_message <> "\n" <> ANSI.reset() <> "\n" + end) + end + end + + property "prints an error message an keep asking if the input length is not + in the range" do + check all min <- integer(1..50), + max <- integer((min + 1)..100), + input <- string(:alphanumeric, length: min) do + capture_io("\n" <> input <> "\n", fn -> + assert capture_io(:stderr, fn -> + prompt_string("", length: min..max) + end) == + ANSI.red() <> + ANSI.bright() <> + "The value must be #{min} to #{max} characters long.\n" <> + ANSI.reset() <> "\n" + end) + end + end + end + + describe "prompt_integer/2" do + ## Standard cases + + property "prints the given message and prompts for an input" do + check all message <- string(:printable) do + assert capture_io("0\n", fn -> prompt_integer(message) end) == + message <> ": " + end + end + + property "adds the default value to the prompt" do + check all message <- string(:printable), + default <- integer() do + assert capture_io("0\n", fn -> + prompt_integer(message, default: default) + end) == "#{message} [#{default}]: " + end + end + + property "adds the valid range to the prompt" do + check all message <- string(:printable), + min <- integer(0..500), + max <- integer((min + 1)..1000), + input <- integer(min..max) do + assert capture_io("#{input}\n", fn -> + prompt_integer(message, range: min..max) + end) == "#{message} (#{min}-#{max}): " + end + end + + property "returns the user input as an integer" do + check all input <- integer() do + capture_io("#{input}\n", fn -> + assert prompt_integer("") == input + end) + end + end + + property "returns the default value on empty inputs" do + check all default <- integer() do + capture_io("\n", fn -> + assert prompt_integer("", default: default) == default + end) + end + end + + property "returns the user input if it is not empty when there is a default + value" do + check all default <- integer(), + input <- integer(), + input != default do + capture_io("#{input}\n", fn -> + assert prompt_integer("", default: default) == input + end) + end + end + + ## Errors + + test "prints an error message and keeps asking on empty inputs when + there is no default value" do + capture_io("\n0\n", fn -> + assert capture_io(:stderr, fn -> prompt_integer("") end) == + ANSI.red() <> + ANSI.bright() <> + "You must provide a value!\n" <> ANSI.reset() <> "\n" + end) + end + + test "prints an error message and keeps asking if the input is not an + integer" do + capture_io("Value\n0\n", fn -> + assert capture_io(:stderr, fn -> prompt_integer("") end) == + ANSI.red() <> + ANSI.bright() <> + "The value must be an integer.\n" <> ANSI.reset() <> "\n" + end) + end + + property "prints an error message and keeps asking if the input is not in + range" do + check all min <- integer(1..500), + max <- integer((min + 1)..1000), + input <- integer(min..max) do + capture_io("0\n#{input}\n", fn -> + assert capture_io(:stderr, fn -> + prompt_integer("", range: min..max) + end) == + ANSI.red() <> + ANSI.bright() <> + "The value must be between #{min} and #{max}.\n" <> + ANSI.reset() <> "\n" + end) + end + end + end + + describe "yes?/2" do + ## Standard cases + + property "prints the given message and prompts for an input" do + check all message <- string(:printable) do + assert capture_io("y\n", fn -> yes?(message) end) == + message <> " (y/n) " + end + end + + property "hilights the default yes" do + check all message <- string(:printable) do + assert capture_io("\n", fn -> yes?(message, default: :yes) end) == + message <> " [Y/n] " + end + end + + property "hilights the default no" do + check all message <- string(:printable) do + assert capture_io("\n", fn -> yes?(message, default: :no) end) == + message <> " [y/N] " + end + end + + property "returns `true` for y, Y, yes, YES, Yes" do + check all input <- member_of(~w(y Y yes YES Yes)) do + capture_io(input <> "\n", fn -> + assert yes?("") == true + end) + end + end + + property "returns `false` for n, N, no, NO, No" do + check all input <- member_of(~w(n N no NO No)) do + capture_io(input <> "\n", fn -> + assert yes?("") == false + end) + end + end + + property "returns the default value on empty inputs" do + check all default <- member_of([:yes, :no]) do + capture_io("\n", fn -> + assert yes?("", default: default) == (default == :yes) + end) + end + end + + property "returns the user input if it is not empty when there is a default + value" do + check all default <- member_of([:yes, :no]), + input <- member_of(~w(y n)) do + capture_io(input <> "\n", fn -> + assert yes?("", default: default) == (input == "y") + end) + end + end + + ## Errors + + test "prints an error message and keeps asking on empty inputs when + there is no default value" do + capture_io("\ny\n", fn -> + assert capture_io(:stderr, fn -> yes?("") end) == + ANSI.red() <> + ANSI.bright() <> + "You must answer yes or no.\n" <> ANSI.reset() <> "\n" + end) + end + + property "prints an error message and keeps asking on invalid inputs" do + check all invalid <- string(:printable), + invalid not in ~w(y Y yes YES Yes n N no NO No) do + capture_io(invalid <> "\ny\n", fn -> + assert capture_io(:stderr, fn -> yes?("") end) == + ANSI.red() <> + ANSI.bright() <> + "You must answer yes or no.\n" <> ANSI.reset() <> "\n" + end) + end + end + end + + # Choice list generator. + defp choice_list, + do: list_of({atom(:alphanumeric), string(:printable)}, min_length: 1) + + # Formats the choices as expected. + defp format_choices(choices) do + choices + |> Keyword.values() + |> Enum.with_index(1) + |> Enum.map(fn {choice, i} -> " #{i}. #{choice}\n" end) + |> Enum.join() + end + + describe "choose/3" do + ## Standard cases + + property "prints the given message and list of options, and prompts the user + for a choice" do + check all message <- string(:printable), + choices <- choice_list() do + assert capture_io("1\n", fn -> choose(message, choices) end) == + message <> "\n\n" <> format_choices(choices) <> "\nChoice: " + end + end + + property "adds the default value to the choice prompt" do + check all message <- string(:printable), + choices <- choice_list(), + default <- member_of(Keyword.keys(choices)) do + default_index = + choices + |> Enum.find_index(&(elem(&1, 0) == default)) + |> Kernel.+(1) + |> Integer.to_string() + + assert capture_io("1\n", fn -> + choose(message, choices, default: default) + end) == + message <> + "\n\n" <> + format_choices(choices) <> "\nChoice [#{default_index}]: " + end + end + + property "returns the user choice" do + check all message <- string(:printable), + choices <- choice_list(), + input <- integer(1..length(choices)) do + capture_io("#{input}\n", fn -> + assert choose(message, choices) == + choices |> Enum.at(input - 1) |> elem(0) + end) + end + end + + property "returns the default value on empty inputs" do + check all message <- string(:printable), + choices <- choice_list(), + default <- member_of(Keyword.keys(choices)) do + capture_io("\n", fn -> + assert choose(message, choices, default: default) == default + end) + end + end + + property "returns the user choice if it is not empty when there is a default + value" do + check all message <- string(:printable), + choices <- choice_list(), + default <- member_of(Keyword.keys(choices)), + input <- integer(1..length(choices)) do + capture_io("#{input}\n", fn -> + assert choose(message, choices, default: default) == + choices |> Enum.at(input - 1) |> elem(0) + end) + end + end + + ## Errors + + test "does not accept empty lists of choices" do + assert_raise FunctionClauseError, fn -> choose("", []) end + end + + test "prints an error message and keeps asking on empty choices when + there is no default value" do + capture_io("\n1\n", fn -> + assert capture_io(:stderr, fn -> choose("", a: "a") end) == + ANSI.red() <> + ANSI.bright() <> + "You must make a choice!\n" <> ANSI.reset() <> "\n" + end) + end + + test "prints an error message and keeps asking on invalid choices" do + capture_io("3\n1", fn -> + assert capture_io(:stderr, fn -> choose("", a: "a", b: "b") end) == + ANSI.red() <> + ANSI.bright() <> + "The choice must be an integer between 1 and 2.\n" <> + ANSI.reset() <> "\n" + end) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..f761b96 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.configure(formatters: [ExUnit.CLIFormatter, ExUnitNotifier]) +ExUnit.start()