Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new syntax, fix credo #18

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Owner

@expede expede Sep 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a more descriptive term than block? handleable? protected? errorable? Block is very general and could mean anything when reading through the code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was actually paralyzed via Decision Paralysis for a long while so I chose the most simple and descriptive name, even if it is overly generic, and left it up to you if you wanted it changed. ^.^;

I did a search through a variety of libraries though and found it an unused function/macro name surprisingly, so it seemed worth grabbing. :-)

I'd personally think block is overall fine though, it is a generic block that can be enhanced later with far more functionality if it is deemed useful, and renaming it later or having 20 slightly differently named variants for different purposes seems excessive to me. ^.^;

I can rename it though if you wish?

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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I use the conversion function on occasion so I needed it exposed in at least the base method here. ^.^;

a <- {:ok, 2}
b = a * 2
_ = 42
c <- {:blah, "Failed: #{b}"}
c * 2
else
Copy link
Owner

@expede expede Sep 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Hmm, I wonder if there's a more descriptive word than else here? We have something preeeeetty close to try/rescue here... perhaps rescue, handle, or errors do?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well we can only use an Elixir block keyword if we want it block level here, which I really think we should for consistency with the rest of the language, so rescue is possible, however I was thinking of leaving rescue and catch open for possible exception handling later too, so that really only left things like else and after and so forth, and as with rescue/catch, after also has possible later use with message handling (I've envisioned possible receive block enhancements in this as well later). So if you have any ideas?

%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)
1 change: 1 addition & 0 deletions lib/exceptional.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
234 changes: 234 additions & 0 deletions lib/exceptional/block.ex
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions lib/exceptional/normalize.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/exceptional/raise.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 1 addition & 4 deletions lib/exceptional/safe.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions lib/exceptional/value.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
1 change: 1 addition & 0 deletions test/exceptional_test.exs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down