diff --git a/lib/upshot.ex b/lib/upshot.ex index 0ceddec..c334c9d 100644 --- a/lib/upshot.ex +++ b/lib/upshot.ex @@ -1,18 +1,2 @@ defmodule Upshot do - @moduledoc """ - Documentation for `Upshot`. - """ - - @doc """ - Hello world. - - ## Examples - - iex> Upshot.hello() - :world - - """ - def hello do - :world - end end diff --git a/lib/upshot/errors/expect_error.ex b/lib/upshot/errors/expect_error.ex new file mode 100644 index 0000000..c3f8b43 --- /dev/null +++ b/lib/upshot/errors/expect_error.ex @@ -0,0 +1,19 @@ +defmodule Upshot.ExpectError do + defexception message: "" + alias Upshot.ExpectError + + @moduledoc """ + Raised when a call to `Result.expect/2` or `Option.expect/2` fails. + """ + + @type t :: %ExpectError{ + :__exception__ => true, + message: String.t() + } + + @impl Exception + def exception(message) when is_binary(message), do: %ExpectError{message: message} + + @impl Exception + def message(%ExpectError{message: message}), do: message +end diff --git a/lib/upshot/errors/unwrap_error.ex b/lib/upshot/errors/unwrap_error.ex new file mode 100644 index 0000000..436654c --- /dev/null +++ b/lib/upshot/errors/unwrap_error.ex @@ -0,0 +1,19 @@ +defmodule Upshot.UnwrapError do + defexception message: nil + alias Upshot.UnwrapError + + @moduledoc """ + Raised when a call to `Result.unwrap/1` or `Option.unwrap/1` fails. + """ + + @type t :: %UnwrapError{ + :__exception__ => true, + message: String.t() + } + + @impl Exception + def exception(message), do: %UnwrapError{message: message} + + @impl Exception + def message(%UnwrapError{message: message}), do: message +end diff --git a/lib/upshot/option.ex b/lib/upshot/option.ex new file mode 100644 index 0000000..87f29fa --- /dev/null +++ b/lib/upshot/option.ex @@ -0,0 +1,228 @@ +defmodule Upshot.Option do + alias Upshot.{ExpectError, Option.Proto, Result, UnwrapError} + + @moduledoc """ + A protocol for interacting with optional types. + """ + + @type t :: Proto.t() + @type map_fn :: (any -> t) + @type predicate_fn :: (any -> boolean) + + @doc """ + Create a new option from value. + """ + @spec some(any) :: t + def some(value), do: {:ok, value} + + @doc """ + Create a new option. + """ + @spec none :: t + def none, do: :error + + @doc """ + Does the option contain a value? + """ + @spec some?(t) :: boolean + defdelegate some?(option), to: Proto + + @doc """ + Does the option contain no value? + """ + @spec none?(t) :: boolean + defdelegate none?(option), to: Proto + + @doc """ + Convert an option into a result. + + If the option is some then it will contain the some, otherwise it will use the + provided error value. + """ + @spec ok_or(t, any) :: Result.t() + def ok_or(option, error) do + if Proto.some?(option), + do: option, + else: {:error, error} + end + + @doc """ + Convert an option into a result mapping a some value into an ok value, and + none into an error using the provided function. + """ + @spec ok_or_else(t, map_fn) :: t + def ok_or_else(option, err_callback) when is_function(err_callback, 1) do + if Proto.some?(option), + do: option, + else: safe_callback(option, err_callback) + end + + @doc """ + A logical and. + + Returns none if the option is none, otherwise the other option. + """ + @spec and_(t, t) :: t + def and_(lhs, rhs) do + if Proto.none?(lhs), + do: lhs, + else: rhs + end + + @doc """ + Returns none, if the option is none, otherwise ok_callback with the wrapped + value and returns the result. + """ + @spec and_then(t, map_fn) :: t + def and_then(option, ok_callback) when is_function(ok_callback, 1) do + if Proto.none?(option), + do: option, + else: safe_callback(option, ok_callback) + end + + @doc """ + Returns none if the option is none, otherwise calls predicate with the wrapped + value returns the wrapped value if the predicate returns true, otherwise none. + """ + @spec filter(t, predicate_fn) :: t + def filter(option, predicate) do + case Proto.unwrap(option) do + {:ok, value} -> + if apply(predicate, [value]), + do: {:ok, value}, + else: none() + + :error -> + option + end + rescue + _error -> none() + end + + @doc """ + Returns the option if it contains a value, otherwise returns the alternative + option. + """ + @spec or_(t, t) :: t + def or_(lhs, rhs) do + if Proto.some?(lhs), + do: lhs, + else: rhs + end + + @doc """ + Returns the option if it contains a value, otherwise calls err_callback and + returns the result. + """ + @spec or_else(t, (() -> t)) :: t + def or_else(option, err_callback) when is_function(err_callback, 0) do + if Proto.some?(option) do + option + else + apply(err_callback, []) + end + rescue + _error -> none() + end + + @doc """ + Returns some if exactly one of the options are some, otherwise none. + """ + @spec xor_(t, t) :: t + def xor_(lhs, rhs) do + cond do + Proto.some?(lhs) && Proto.none?(rhs) -> lhs + Proto.none?(lhs) && Proto.some?(rhs) -> rhs + true -> none() + end + end + + @doc """ + Zips the two options together. + + If both options are some, returns a some containing a tuple of both values, otherwise none. + """ + @spec zip(t, t) :: t + def zip(lhs, rhs) do + with {:ok, lhs} <- Proto.unwrap(lhs), + {:ok, rhs} <- Proto.unwrap(rhs) do + {:ok, {lhs, rhs}} + else + _error -> none() + end + end + + @doc """ + Unwraps the option and returns the value. + + Raises an `ExpectError` on failure with `message`. + """ + @spec expect(t, String.t()) :: any | no_return + def expect(option, message) when is_binary(message) do + case Proto.unwrap(option) do + {:ok, value} -> value + :error -> raise ExpectError, message + end + end + + @doc """ + Unwraps the option presuming it's none. + + Raises an `ExpectError` when the option is some with `message`. + """ + @spec expect_none(t, String.t()) :: t | no_return + def expect_none(option, message) when is_binary(message) do + if Proto.some?(option) do + raise ExpectError, message + else + none() + end + end + + @doc """ + Unwraps the option and returns the value. + + Raises an `UnwrapError` on failure. + """ + @spec unwrap(t) :: any | no_return + def unwrap(option) do + case Proto.unwrap(option) do + {:ok, value} -> value + :error -> raise UnwrapError, "Attempt to unwrap a none" + end + end + + @doc """ + Unwraps an option and returns a none. + + Raises an `UnwrapError` on failure. + """ + @spec unwrap_none(t) :: t | no_return + def unwrap_none(option) do + if Proto.none?(option) do + option + else + raise UnwrapError, "Attempt to unwrap a some" + end + end + + @doc """ + Unwraps an option and returns it's contained value or a default. + """ + @spec unwrap_or_default(t, any) :: any + def unwrap_or_default(option, default) do + case Proto.unwrap(option) do + {:ok, value} -> value + :error -> default + end + end + + # Safely execute a mapping function, catching an exceptions and converting + # them into a result. + @spec safe_callback(t, map_fn) :: Result.t() + defp safe_callback(option, callback) when is_function(callback, 1) do + apply(callback, [option]) + rescue + error -> {:error, error} + end +end diff --git a/lib/upshot/option/proto.ex b/lib/upshot/option/proto.ex new file mode 100644 index 0000000..1145a3d --- /dev/null +++ b/lib/upshot/option/proto.ex @@ -0,0 +1,16 @@ +defprotocol Upshot.Option.Proto do + @moduledoc """ + A protocol for interacting with optional types. + """ + + @fallback_to_any true + + @spec some?(t) :: boolean + def some?(_option) + + @spec none?(t) :: boolean + def none?(_option) + + @spec unwrap(t) :: {:ok, any} | :error + def unwrap(_option) +end diff --git a/lib/upshot/option/proto_any.ex b/lib/upshot/option/proto_any.ex new file mode 100644 index 0000000..35a5fe7 --- /dev/null +++ b/lib/upshot/option/proto_any.ex @@ -0,0 +1,26 @@ +defimpl Upshot.Option.Proto, for: Any do + @moduledoc """ + Implements the option protocol for arbitrary terms. + """ + + @doc """ + Returns `true` for `{:ok, value}` tuples, otherwise `false`. + """ + @spec some?(any) :: boolean + def some?({:ok, _value}), do: true + def some?(_option), do: false + + @doc """ + Returns `false` for `{:ok, value}` tuples, otherwise `true`. + """ + @spec none?(any) :: boolean + def none?({:ok, _value}), do: false + def none?(_option), do: true + + @doc """ + Returns the internal value of `{:ok, value}` or `:error`. + """ + @spec unwrap(any) :: {:ok, any} | :error + def unwrap({:ok, value}), do: value + def unwrap(_option), do: :error +end diff --git a/lib/upshot/result.ex b/lib/upshot/result.ex new file mode 100644 index 0000000..1609e25 --- /dev/null +++ b/lib/upshot/result.ex @@ -0,0 +1,210 @@ +defmodule Upshot.Result do + alias Upshot.{ExpectError, Option, Result.Proto, UnwrapError} + + @moduledoc """ + Functions for working with types which implement the result protocol. + """ + + @type t :: Proto.t() + @type map_fn :: (any -> {:ok, any} | {:error, any}) + + @doc """ + Create a new result with an ok value. + """ + @spec ok(any) :: t + def ok(value), do: {:ok, value} + + @doc """ + Create a new result with an error value. + """ + @spec error(any) :: t + def error(reason), do: {:error, reason} + + @doc """ + Convert a result into an option. + """ + @spec to_option(t) :: Option.t() + def to_option(result) do + case Proto.unwrap(result) do + {:ok, value} -> {:ok, value} + _error -> :error + end + end + + @doc """ + Is the result okay? + """ + @spec ok?(t) :: boolean + defdelegate ok?(result), to: Proto + + @doc """ + Is the result an error? + """ + @spec error?(t) :: boolean + defdelegate error?(result), to: Proto + + @doc """ + Returns true if the result is ok and it's value equals the test value. + """ + @spec contains?(t, any) :: boolean + def contains?(result, test_value), do: Proto.unwrap(result) == {:ok, test_value} + + @doc """ + Returns true if the result is an error and it's value equals the test value. + """ + @spec contains_err?(t, any) :: boolean + def contains_err?(result, test_value), do: Proto.unwrap(result) == {:error, test_value} + + @doc """ + Maps an ok result into a new result using the provided function, leaving an + error result untouched. + """ + @spec map(t, map_fn) :: t + def map(result, ok_callback) when is_function(ok_callback, 1) do + with {:ok, value} <- Proto.unwrap(result), + {:ok, value} <- safe_callback(value, ok_callback) do + {:ok, value} + else + _error -> result + end + end + + @doc """ + Maps an ok result into a new result using the provided function, replacing an + error value with the default. + """ + @spec map_or(t, map_fn, any) :: t + def map_or(result, ok_callback, default) when is_function(ok_callback, 1) do + with {:ok, value} <- Proto.unwrap(result), + {:ok, value} <- safe_callback(value, ok_callback) do + {:ok, value} + else + _error -> {:ok, default} + end + end + + @doc """ + Maps a result to another result by applying `ok_callback` or `err_callback` + depending on whether the result is successful or not. + """ + @spec map_or_else(t, map_fn, map_fn) :: t + def map_or_else(result, ok_callback, err_callback) do + case Proto.unwrap(result) do + {:ok, value} -> safe_callback(value, ok_callback) + {:error, value} -> safe_callback(value, err_callback) + end + end + + @doc """ + Maps an error result into another result by applying the function to an error + value and leaving an ok value untouched. + """ + @spec map_err(t, map_fn) :: t + def map_err(result, err_callback) do + case Proto.unwrap(result) do + {:error, value} -> safe_callback(value, err_callback) + _other -> result + end + end + + @doc """ + A logical and. + + If `lhs` is ok, returns `rhs`, otherwise returns `lhs`. + """ + @spec and_(t, t) :: t + def and_(lhs, rhs) do + case Proto.unwrap(lhs) do + {:ok, _value} -> rhs + _other -> lhs + end + end + + @doc """ + Calls `ok_callback` if the result is ok, otherwise returns the error value. + """ + @spec and_then(t, map_fn) :: t + def and_then(result, ok_callback) when is_function(ok_callback, 1) do + case Proto.unwrap(result) do + {:ok, value} -> safe_callback(value, ok_callback) + _other -> result + end + end + + @doc """ + A logical or. + + If `lhs` is an error, returns `rhs`, otherwise returns `lhs`. + """ + @spec or_(t, t) :: t + def or_(lhs, rhs) do + case Proto.unwrap(lhs) do + {:error, _value} -> rhs + _ -> lhs + end + end + + @doc """ + Unwraps the result and returns the value. + + Raises an `ExpectError` on failure with `message`. + """ + @spec expect(t, String.t()) :: any | no_return + def expect(result, message) when is_binary(message) do + case Proto.unwrap(result) do + {:ok, value} -> + value + + {:error, _} -> + raise ExpectError, message + end + end + + @doc """ + Unwraps the result and returns the value. + + Raises an `UnwrapError` on failure. + """ + @spec unwrap(t) :: any | no_return + def unwrap(result) do + case Proto.unwrap(result) do + {:ok, value} -> value + {:error, _} -> raise UnwrapError, "Attempt to unwrap error result" + end + end + + @doc """ + Returns the contained ok value, or a provided default. + """ + @spec unwrap_or(t, any) :: any + def unwrap_or(result, default) do + case Proto.unwrap(result) do + {:ok, value} -> value + _other -> default + end + end + + @doc """ + Returns the contained ok value, or computes it with the provided function. + """ + @spec unwrap_or_else(t, map_fn) :: any + def unwrap_or_else(result, err_callback) when is_function(err_callback, 1) do + case Proto.unwrap(result) do + {:ok, value} -> + value + + {:error, value} -> + value + |> safe_callback(err_callback) + |> unwrap() + end + end + + # Calls a callback, but rescues exceptions and converts them results. + @spec safe_callback(any, map_fn) :: t + defp safe_callback(value, callback) do + apply(callback, [value]) + rescue + exception -> {:error, exception} + end +end diff --git a/lib/upshot/result/proto.ex b/lib/upshot/result/proto.ex new file mode 100644 index 0000000..b6d3b38 --- /dev/null +++ b/lib/upshot/result/proto.ex @@ -0,0 +1,18 @@ +defprotocol Upshot.Result.Proto do + @moduledoc """ + The result protocol. + + Implement this for your own result types, as needed. + """ + + @fallback_to_any true + + @spec ok?(t) :: boolean + def ok?(_result) + + @spec error?(t) :: boolean + def error?(_result) + + @spec unwrap(t) :: {:ok, any} | {:error, any} + def unwrap(_result) +end diff --git a/lib/upshot/result/proto_any.ex b/lib/upshot/result/proto_any.ex new file mode 100644 index 0000000..434f65e --- /dev/null +++ b/lib/upshot/result/proto_any.ex @@ -0,0 +1,31 @@ +defimpl Upshot.Result.Proto, for: Any do + @moduledoc """ + Implements the result protocol for arbitrary terms. + """ + + @doc """ + Returns `true` for `{:ok, value}` and `:ok`, otherwise `false`. + """ + @spec ok?(any) :: boolean + def ok?({:ok, _value}), do: true + def ok?(:ok), do: true + def ok?(_result), do: false + + @doc """ + Returns `true` for `{:error, reason}` and `:error`. + """ + @spec error?(any) :: boolean + def error?({:error, _reason}), do: true + def error?(%{:__exception__ => true}), do: true + def error?(_result_), do: true + + @doc """ + Unwrap a result, if possible. + + Returns the value of an `{:ok, value}` tuple, otherwise `{:error, reason}`. + """ + @spec unwrap(any) :: {:ok, any} | {:error, any} + def unwrap({:ok, value}), do: {:ok, value} + def unwrap({:error, value}), do: {:error, value} + def unwrap(%{:__exception__ => true} = error), do: {:error, error} +end