Current section

Files

Jump to
compare_chain lib compare_chain.ex
Raw

lib/compare_chain.ex

defmodule CompareChain do
@doc """
Macro that performs chained comparison using operators like `<`.
## Examples
Basic comparison:
```
iex> import CompareChain
iex> compare?(1 < 2)
true
```
Chained comparison:
```
iex> import CompareChain
iex> compare?(1 < 2 < 3)
true
```
Comparisons joined by logical operators:
```
iex> import CompareChain
iex> compare?(1 >= 2 and 4 > 3)
false
```
"""
defmacro compare?(expr) do
ast = quote(do: unquote(expr))
do_compare?(ast, CompareChain.DefaultCompare)
end
@doc """
Similar to `compare?/1` except you can provide a module that defines a
`compare/2` for semantic comparisons.
This is similar to how you can provide a module as the second argument to
`Enum.sort/2`.
## Examples
Basic comparison:
```
iex> import CompareChain
iex> a = ~D[2017-03-31]
iex> b = ~D[2017-04-01]
iex> compare?(a < b, Date)
true
```
Normally, `~D[2017-03-31] < ~D[2017-04-01]` evaluates to `false` because of
structural comparison.
Chained comparison:
```
iex> import CompareChain
iex> a = ~D[2017-03-31]
iex> b = ~D[2017-04-01]
iex> c = ~D[2017-04-02]
iex> compare?(a < b < c, Date)
true
```
Comparisons joined by logical operators:
```
iex> import CompareChain
iex> a = ~T[15:00:00]
iex> b = ~T[16:00:00]
iex> c = ~T[17:00:00]
iex> compare?(a < b and b > c, Time)
false
```
Custom module:
```
iex> import CompareChain
iex> defmodule AlwaysGreaterThan do
iex> def compare(_left, _right), do: :gt
iex> end
iex> compare?(1 > 2 > 3, AlwaysGreaterThan)
true
```
"""
defmacro compare?(expr, module) do
ast = quote(do: unquote(expr))
do_compare?(ast, module)
end
# Calls `chain` on the arguments of `and` and `or`.
# E.g. for `a < b < c and d > e`,
#
# ```
# and
# / \
# (a < b < c) (c > d)
# ```
#
# becomes
#
# ```
# and
# / \
# chain(a < b < c) chain(c > d)
# ```
defp do_compare?(ast, module) do
ast
|> Macro.prewalker()
# Build a stack of `and`s and `or`s and their right arguments.
# This works because
# 1. the `and` and `or` combinations are always higher than `<` and
# friends in the ast and
# 2. the right argument will always be a combination leaf, never another
# combination.
# The head of the stack will be special: `{nil, nil, node}`.
|> Enum.reduce_while([], fn
{c, meta, [_left, right]}, acc when c in [:and, :or] ->
{:cont, [{c, meta, right} | acc]}
node, acc ->
{:halt, [{nil, nil, node} | acc]}
end)
# Unwind stack back into the ast while calling `chain` on the nodes.
|> Enum.reduce(nil, fn
{nil, nil, node}, nil ->
chain_or_raise(node, module)
{c, meta, node}, acc ->
{c, meta, [acc, chain_or_raise(node, module)]}
end)
end
defp chain_or_raise(node, module) do
node = chain(node, module)
if node == :no_comparison_operators_found do
raise ArgumentError, CompareChain.ErrorMessage.chain_error_message()
else
node
end
end
# Transforms a chain of comparisons into a series of `and`'d pairs.
# E.g. for `a < b < c`,
#
# ```
# <
# / \
# < c
# / \
# a b
# ```
#
# becomes
#
# ```
# and
# / \
# ~ ~
# / \ / \
# a b b c
# ```
#
# where `~` is roughly `compare(left, right) == :lt`.
defp chain(ast, module) do
ast
|> Macro.prewalker()
# Build up a stack of comparison operators and their right arguments.
# This works because the right is guaranteed to be a comparison leaf, not
# another comparison.
|> Enum.reduce_while([], fn
{:not, _, [_]}, _ ->
raise_on_not()
{op, _, [_left, right]}, acc when op in [:<, :>, :<=, :>=] ->
{:cont, [{op, right} | acc]}
node, acc ->
{:halt, [{nil, node} | acc]}
end)
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(:no_comparison_operators_found, fn [{_, left}, {op, right}], acc ->
{kernel_fun, evals_to} =
case op do
:< -> {:==, :lt}
:> -> {:==, :gt}
:<= -> {:!=, :gt}
:>= -> {:!=, :lt}
end
inner_comparison =
quote do
unquote(module).compare(unquote(left), unquote(right))
end
outer_comparison =
quote do
Kernel.unquote(kernel_fun)(unquote(inner_comparison), unquote(evals_to))
end
if acc == :no_comparison_operators_found do
outer_comparison
else
quote(do: unquote(acc) and unquote(outer_comparison))
end
end)
end
defp raise_on_not() do
raise ArgumentError, CompareChain.ErrorMessage.raise_on_not_message()
end
end