From 28cf0e42ff53bb93066b2f179a1d107fd7bf4313 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Cugnet Date: Thu, 11 Oct 2018 08:56:16 +0200 Subject: [PATCH] Extract from xgen --- .credo.exs | 162 ++++++++++++++++ .dialyzer_ignore | 0 .editorconfig | 18 ++ .envrc | 84 ++++++++ .formatter.exs | 7 + .gitignore | 37 ++++ .gitsetup | 11 ++ CHANGELOG.md | 5 + CONTRIBUTING.md | 112 +++++++++++ LICENSE | 21 ++ README.md | 67 +++++++ config/config.exs | 16 ++ lib/marcus.ex | 361 ++++++++++++++++++++++++++++++++++ mix.exs | 78 ++++++++ mix.lock | 24 +++ shell.nix | 22 +++ test/marcus_test.exs | 450 +++++++++++++++++++++++++++++++++++++++++++ test/test_helper.exs | 2 + 18 files changed, 1477 insertions(+) create mode 100644 .credo.exs create mode 100644 .dialyzer_ignore create mode 100644 .editorconfig create mode 100644 .envrc create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100755 .gitsetup create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 lib/marcus.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 shell.nix create mode 100644 test/marcus_test.exs create mode 100644 test/test_helper.exs 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()