diff --git a/README.md b/README.md index acc8c93..7f38a03 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,69 @@ end #=> "error message" ``` +### [Block](https://hexdocs.pm/exceptional/Exceptional.Block.html) + +Kind of a combination of Elixir's normal +[`with`](https://hexdocs.pm/elixir/Kernel.SpecialForms.html#with/1) +special form in addition to a monad-style `do` pipeline. + +This automatically-wraps every return value with +[`normalize`](https://hexdocs.pm/exceptional/Exceptional.Normalize.html). + +```elixir +block do + a <- {:ok, 2} + b = a * 2 + c <- {:ok, b * 2} + c * 2 +end +#=> 16 + +block do + a <- {:ok, 2} + b = a * 2 + _ = 42 + c <- {:error, "Failed: #{b}"} + c * 2 +end +#=> %ErlangError{original: "Failed: 4"} + +conversion_fun = fn + {:blah, reason} -> %ErlangError{original: "Blah: #{reason}"} + e -> e +end +block conversion_fun: conversion_fun do + a <- {:ok, 2} + b = a * 2 + _ = 42 + c <- {:blah, "Failed: #{b}"} + c * 2 +else + %ErlangError{original: "Blah: "<>_} = exc -> exc + _ -> {:error, "unknown error"} +end +#=> %ErlangError{original: "Blah: Failed: 4"} + +block! do + a <- {:ok, 2} + b = a * 2 + _ = 42 + c <- {:error, "Failed: #{b}"} + c * 2 +end +#=> ** (ErlangError) Erlang error: "Failed: 4" + +# Early return: + +block do + a <- {:ok, 2} + b = a * 2 + :wrong <- b * 2 # Returning 8 here due to wrong match + b * 4 +end +8 +``` + ## Related Packages - [Phoenix/Exceptional](https://hex.pm/packages/phoenix_exceptional) diff --git a/lib/exceptional.ex b/lib/exceptional.ex index 733a470..3a4a372 100644 --- a/lib/exceptional.ex +++ b/lib/exceptional.ex @@ -23,6 +23,7 @@ defmodule Exceptional do defmacro __using__(opts \\ []) do quote bind_quoted: [opts: opts] do + use Exceptional.Block, opts use Exceptional.Control, opts use Exceptional.Normalize, opts use Exceptional.Pipe, opts diff --git a/lib/exceptional/block.ex b/lib/exceptional/block.ex new file mode 100644 index 0000000..9c93ce8 --- /dev/null +++ b/lib/exceptional/block.ex @@ -0,0 +1,234 @@ +defmodule Exceptional.Block do + @moduledoc ~S""" + Convenience functions to wrap a block of calls similar to `with`. + + ## Convenience `use`s + + Everything: + + use Exceptional.Block + + """ + + defmacro __using__(_) do + quote do + import unquote(__MODULE__) + end + end + + @doc ~S""" + This specifies a block that is tested as normal similar to Elixir's `with`. + + This will auto-normalize every return value, so expect a raw `value` return, + not something like `{:ok, value}` when using `<-`. `=` is unwrapped and + unhandled. + + This requires a `do` body, last expression is final returned value. + - Inside the `do` body it accepts `matcher <- expression` and will early + return if it is a bad match or the expression returns an error. + - Values will be unwrapped as in `normalize` + + This accepts an `else` body, which takes cases to handle error conditions + and transform them as necessary. + + This takes a `conversion_fun: some_fun` optional argument to pass in to the + `normalize` call to transform normalization errors into custom values and/or + errors. + + ## Examples + + iex> use Exceptional.Block + ...> block do + ...> _ <- {:ok, 2} + ...> end + 2 + + iex> use Exceptional.Block + ...> block do + ...> a <- {:ok, 2} + ...> b = a * 2 + ...> c <- {:ok, b * 2} + ...> c * 2 + ...> end + 16 + + iex> use Exceptional.Block + ...> block do + ...> a <- {:ok, 2} + ...> b = a * 2 + ...> 8 <- b * 2 # Match's supported on the values + ...> b * 4 + ...> end + 16 + + iex> use Exceptional.Block + ...> block do + ...> a <- {:ok, 2} + ...> b = a * 2 + ...> :wrong <- b * 2 # Returning 8 here due to wrong match + ...> b * 4 + ...> end + 8 + + iex> use Exceptional.Block + ...> block do + ...> a <- {:ok, 2} + ...> b = a * 2 + ...> :wrong = b * 2 # Match Exception is raised here + ...> b * 4 + ...> end + ** (MatchError) no match of right hand side value: 8 + + iex> use Exceptional.Block + ...> block do + ...> a <- {:ok, 2} + ...> b = a * 2 + ...> _ = 42 + ...> c <- {:error, "Failed: #{b}"} + ...> c * 2 + ...> end + %ErlangError{original: "Failed: 4"} + + iex> use Exceptional.Block + ...> block do + ...> a <- {:ok, 2} + ...> b = a * 2 + ...> _ = 42 + ...> c <- {:error, "Failed: #{b}"} + ...> c * 2 + ...> else + ...> _ -> {:error, "unknown error"} + ...> end + %ErlangError{original: "unknown error"} + + iex> use Exceptional.Block + ...> conversion_fun = fn + ...> {:blah, reason} -> %ErlangError{original: "Blah: #{reason}"} + ...> e -> e + ...> end + ...> block conversion_fun: conversion_fun do + ...> a <- {:ok, 2} + ...> b = a * 2 + ...> _ = 42 + ...> c <- {:blah, "Failed: #{b}"} + ...> c * 2 + ...> else + ...> %ErlangError{original: "Blah: "<>_} = exc -> exc + ...> _ -> {:error, "unknown error"} + ...> end + %ErlangError{original: "Blah: Failed: 4"} + + """ + defmacro block(opts, bodies \\ []) do + opts = bodies ++ opts + gen_block(opts) + end + + @doc ~S""" + The auto-throwing version of `block`, will raise it's final error. + + ## Examples + + iex> use Exceptional.Block + ...> block! do + ...> a <- {:ok, 2} + ...> b = a * 2 + ...> _ = 42 + ...> c <- {:error, "Failed: #{b}"} + ...> c * 2 + ...> end + ** (ErlangError) Erlang error: "Failed: 4" + + """ + defmacro block!(opts, bodies \\ []) do + opts = bodies ++ opts + body = gen_block(opts) + quote do + # credo:disable-for-lines:1 Credo.Check.Design.AliasUsage + Exceptional.Raise.ensure!(unquote(body)) + end + end + + defp gen_block(opts) do + {:__block__, _meta, do_body} = wrap_block(opts[:do] || throw "Must specify a `do` body clause with at least one expression!") + conversion_fun_ast = + case opts[:conversion_fun] do + nil -> quote do fn x -> x end end + call -> call + end + conversion_fun = gen_unique_var("$conversion_fun") + else_fun_ast = + case opts[:else] do + nil -> quote do fn x -> x end end + clauses -> + # credo:disable-for-lines:7 /Alias|Nesting/ + quote do + fn x -> + x + |> case do + unquote(clauses) + end + |> Exceptional.Normalize.normalize(unquote(conversion_fun)) + end + end + end + else_fun = gen_unique_var("$else_fun") + body = gen_block_body(do_body, conversion_fun, else_fun) + quote generated: true do + unquote(conversion_fun) = unquote(conversion_fun_ast) + unquote(else_fun) = unquote(else_fun_ast) + unquote(body) + end + end + + defp wrap_block({:__block__, _, _} = ast), do: ast + defp wrap_block(ast), do: {:__block__, [], [ast]} + + defp gen_block_body(exprs, conversion_fun, else_fun) + defp gen_block_body([{:<-, meta, [binding, bound]} | exprs], conversion_fun, else_fun) do + value = Macro.var(:"$val", __MODULE__) + next = + case exprs do + [] -> value + _ -> gen_block_body(exprs, conversion_fun, else_fun) + end + {call, gen_meta, args} = + quote generated: true do + # credo:disable-for-lines:1 Credo.Check.Design.AliasUsage + case Exceptional.Normalize.normalize(unquote(bound), unquote(conversion_fun)) do + %{__exception__: _} = unquote(value) -> unquote(else_fun).(unquote(value)) + unquote(binding) = unquote(value) -> unquote(next) + unquote(value) -> unquote(else_fun).(unquote(value)) + end + end + {call, meta ++ gen_meta, args} + end + defp gen_block_body([expr | exprs], conversion_fun, else_fun) do + value = gen_unique_var(:"$val") + next = + case exprs do + [] -> value + _ -> gen_block_body(exprs, conversion_fun, else_fun) + end + quote generated: true do + unquote(value) = unquote(expr) + unquote(next) + end + end + defp gen_block_body(exprs, _conversion_fun, _else_fun) do + throw {:UNHANDLED_EXPRS, exprs} + end + + defp gen_unique_var(name) do + id = Process.get(__MODULE__, 0) + Process.put(__MODULE__, id + 1) + name = + if id === 0 do + String.to_atom(name) + else + String.to_atom("#{name}_#{id}") + end + Macro.var(name, __MODULE__) + end + +end diff --git a/lib/exceptional/normalize.ex b/lib/exceptional/normalize.ex index 82ed3e9..d363eea 100644 --- a/lib/exceptional/normalize.ex +++ b/lib/exceptional/normalize.ex @@ -82,6 +82,7 @@ defmodule Exceptional.Normalize do if Exception.exception?(err), do: err, else: plain {:ok, value} -> value + exc = %{__exception__: _} -> exc value -> conversion_fun.(value) end end diff --git a/lib/exceptional/raise.ex b/lib/exceptional/raise.ex index 771424f..6046e49 100644 --- a/lib/exceptional/raise.ex +++ b/lib/exceptional/raise.ex @@ -51,7 +51,7 @@ defmodule Exceptional.Raise do ** (ArgumentError) raise me """ - @lint {Credo.Check.Design.AliasUsage, false} + # credo:disable-for-lines:8 Credo.Check.Design.AliasUsage defmacro raise_or_continue!(maybe_exception, continue) do quote do require Exceptional.Control @@ -75,7 +75,7 @@ defmodule Exceptional.Raise do ** (ArgumentError) raise me """ - @lint {Credo.Check.Design.AliasUsage, false} + # credo:disable-for-lines:8 Credo.Check.Design.AliasUsage defmacro maybe_exception >>> continue do quote do require Exceptional.Control diff --git a/lib/exceptional/safe.ex b/lib/exceptional/safe.ex index 3b8bb94..18fee9b 100644 --- a/lib/exceptional/safe.ex +++ b/lib/exceptional/safe.ex @@ -78,11 +78,8 @@ defmodule Exceptional.Safe do :error """ + # credo:disable-for-lines:37 /ABCSize|CyclomaticComplexity/ @spec safe(fun) :: fun - @lint [ - {Credo.Check.Refactor.ABCSize, false}, - {Credo.Check.Refactor.CyclomaticComplexity, false} - ] def safe(dangerous) do safe = safe(dangerous, :dynamic) {:arity, arity} = :erlang.fun_info(dangerous, :arity) diff --git a/lib/exceptional/value.ex b/lib/exceptional/value.ex index 924f946..79b9b80 100644 --- a/lib/exceptional/value.ex +++ b/lib/exceptional/value.ex @@ -66,7 +66,7 @@ defmodule Exceptional.Value do ** (Enum.OutOfBoundsError) out of bounds error """ - @lint {Credo.Check.Design.AliasUsage, false} + # credo:disable-for-lines:8 Credo.Check.Design.AliasUsage @spec exception_or_continue(Exception.t | any, fun) :: Exception.t | any defmacro exception_or_continue(maybe_exception, continue) do quote do @@ -101,7 +101,7 @@ defmodule Exceptional.Value do ** (Enum.OutOfBoundsError) out of bounds error """ - @lint {Credo.Check.Design.AliasUsage, false} + # credo:disable-for-lines:8 Credo.Check.Design.AliasUsage defmacro maybe_exception ~> continue do quote do require Exceptional.Control diff --git a/mix.exs b/mix.exs index df36675..a7c9ead 100644 --- a/mix.exs +++ b/mix.exs @@ -26,7 +26,7 @@ defmodule Exceptional.Mixfile do start_permanent: Mix.env == :prod, deps: [ - {:credo, "~> 0.5", only: [:dev, :test]}, + {:credo, "~> 0.8", only: [:dev, :test]}, {:dialyxir, "~> 0.3", only: :dev}, {:earmark, "~> 1.0", only: :dev}, diff --git a/test/exceptional_test.exs b/test/exceptional_test.exs index 283504a..c7033a3 100644 --- a/test/exceptional_test.exs +++ b/test/exceptional_test.exs @@ -1,6 +1,7 @@ defmodule Exceptional.PipeTest do use ExUnit.Case + doctest Exceptional.Block, [import: true] doctest Exceptional.Control, [import: true] doctest Exceptional.Normalize, [import: true] doctest Exceptional.Pipe, [import: true]