defmodule Loggy do use Agent # Note: this text should match what's in the first section of README.md @moduledoc """ Loggy is a simple logging tool designed for Elixir CLIs, as the default logger does not support colours in escripts. Loggy does **not** integrate with Logger. Instead, it's tailored around CLI usage. Loggy supports configuration through direct passthrough of parsed arguments from OptionParser. `verbose`, `debug`, and `color`/`colour` are supported. `verbose` supports both a binary flag and multiple levels. There are two keyword structures that Loggy relies on for what to print and when. These are `config` and `opts`. `config` is determined by your user and should be passed straight from OptionsParser. It determines whether to use colour, debugging, and verbosity level to use. You shouldn't change `config` further than what the user does. `opts` are the arguments passed to the log function. These are level, verbosity level required to print, whether the error should be fatal, etc. You can override change `opts[level]` to change default behaviour for a certain level, and `opts[:all]` to change all. TODO: full runtime examples """ @doc """ Initialise Loggy and update it with the given options. See `configure/1`. Note that this will be done automatically if `applications` has not been made to exclude it in your project's `mix.exs` ## Examples {:ok, pid} = Loggy.start(:normal, user_opts: [verbose: 3, debug: true]) """ @spec start(any, user_opts: keyword, fn_params: keyword) :: {:ok, Loggy} def start(_, state) do {:ok, pid} = Agent.start_link( fn -> [ user_opts: default_user_opts(), fn_params: default_fn_params() ] end, name: __MODULE__ ) set_user_opts(Keyword.get(state, :user_opts, [])) set_fn_params(Keyword.get(state, :fn_params, [])) {:ok, pid} end @spec default_user_opts :: keyword defp default_user_opts do Application.get_env(:loggy, :user_opts) end @spec default_fn_params :: keyword defp default_fn_params do Application.get_env(:loggy, :fn_params) end @spec get() :: [user_opts: keyword, fn_params: keyword] defp get() do Agent.get(__MODULE__, & &1) end @spec get_user_opts :: keyword def get_user_opts do Keyword.get(get(), :user_opts, []) end @spec get_fn_params :: keyword def get_fn_params do Keyword.get(get(), :fn_params, []) end # Merge all keywords in given list @spec cascade_merge([keyword]) :: any defp cascade_merge([last]) do last end @spec cascade_merge([keyword, ...]) :: keyword defp cascade_merge([a | rest]) do Keyword.merge(a, cascade_merge(rest), &deep_merge/3) end # Joining function of cascade_merge, passed to Keyword.merge @spec deep_merge(any, keyword | any, keyword | any) :: keyword | any defp deep_merge(_key, left, right) do if Keyword.keyword?(left) && Keyword.keyword?(right) do # We merge recursively Keyword.merge(left, right, &deep_merge/3) else # Not both keywords, so right overrides left # Will cause issues if structures mismatch, i.e. left is keyword and right is not. right end end @spec set_user_opts(keyword) :: :ok def set_user_opts(new) do Agent.update(__MODULE__, fn state -> cascade_merge([state, [user_opts: new]]) end) end @spec set_fn_params(keyword) :: :ok def set_fn_params(new) do Agent.update(__MODULE__, fn state -> cascade_merge([state, [fn_params: new]]) end) end @doc """ Wrapper to write debug message, if debugging is enabled. See `log/3` ## Examples iex> Loggy.set_user_opts(debug: true) iex> Loggy.debug("Hello!") #=> 🐛 Hello! :ok iex> Loggy.set_user_opts(debug: false) iex> Loggy.debug("Hello!") :debug_skip """ @spec debug(String.t(), keyword) :: :ok | :debug_skip | :verbosity_skip def debug(str, opts \\ []) when is_list(opts) do log(str, Keyword.put(opts, :level, :debug)) end @doc """ Wrapper to write info message. See `log/3` ## Examples iex> Loggy.info("Hey hey hey!") #=> â„šī¸ Hey hey hey! :ok """ @spec info(String.t(), keyword) :: :ok | :verbosity_skip def info(str, opts \\ []) when is_list(opts) do log(str, Keyword.put(opts, :level, :info)) end @doc """ Wrapper function to write warning message. See `log/3` ## Examples iex> Loggy.warn("Please do not the.") #=> âš ī¸ Please do not the. :ok """ @spec warn(String.t(), keyword) :: :ok | :verbosity_skip def warn(str, opts \\ []) when is_list(opts) do log(str, Keyword.put(opts, :level, :warn)) end @doc """ Wrapper to write warning, by default to stderr. See `log/3` Unlike `error!/2`, is not fatal and doesn't halt the program. ## Examples iex> Loggy.error("You really did it this time.") #=> đŸšĢ You really did it this time. :ok """ @spec error(String.t(), keyword) :: :ok | :verbosity_skip def error(str, opts \\ []) when is_list(opts) do log(str, Keyword.put(opts, :level, :error)) end @doc """ Wrapper to write error, by default to stderr. See `log/3` Unlike `error/2`, also exits with `System.stop(1)`. Note that this exits from the calling process, not the Loggy agent. As such, it will probably shut down your CLI. Bear this in mind if your program has to perform any clean-up tasks after crashing. Overrides verbosity to assure that the error is always printed before exiting. ## Examples iex> Loggy.error("This won't do.", verbose: 1) :verbosity_skip Loggy.error!("And that's a wrap!", verbose: 1) #=> đŸšĢ And that's a wrap! # [The program exits] """ @spec error!(String.t(), keyword) :: no_return def error!(str, opts \\ []) do opts = Keyword.merge( opts, verbose: 0, fatal: true, level: :error ) error(str, opts) end @doc """ Main logging function of loggy. You'll most likely want to use the `debug/2`, `info/2`, `warn/2`, and `error/2` functions though, but manually passing the level might be helpful e.g. in order to promote warnings to errors if enough occur. ## Opts Valid opts are... * `level`: one of `:debug`, `:info`, `:warn`, and `:error` * `verbose`: integer or boolean. Internally converted to integer. Note that it's the required verbosity of a logging statement. In other words, a call with `verbosity: 2` will only print out if `verbosity >= 2` in the Loggy config. Usually, this would be achieved by passing the `--verbose` flag twice, or `-vv` for short. * `fatal`: if `true`, will exit the program with `System.stop/1` and an exit value of 1. TODO: conditional warn/error! example... """ @spec log(String.t(), keyword) :: :ok | :verbosity_skip | :debug_skip def log(str, opts) when is_binary(str) and is_list(opts) do user_opts = get_user_opts() level = opts[:level] opts = cascade_merge([get_fn_params()[:all], get_fn_params()[level], opts]) |> verbose_as_int cond do user_opts[:verbose] < opts[:verbose] -> :verbosity_skip level == :debug && !user_opts[:debug] -> :debug_skip true -> output = opts[:format].(str) device = opts[:device] IO.puts(device, output) if opts[:fatal] do System.stop(1) else :ok end end end # Utility function for converting verbosity boolean to verbosity level. # Implicitly handles nil (returns false, thus 0) @spec to_int(integer) :: integer @spec to_int(atom) :: integer defp to_int(val) when is_integer(val) do val end defp to_int(val) do if val, do: 1, else: 0 end # Assure that :verbose is an integer # Used for both agent config and function call opts @spec verbose_as_int(keyword) :: keyword defp verbose_as_int(config) do verbose = config |> Keyword.get(:verbose, 0) |> to_int Keyword.put(config, :verbose, verbose) end end