| defmodule Plausible.AssertMatches do |
| @moduledoc """ |
| Pattern match assertions wrapper macro extending it with checks expressed |
| directly within the pattern. |
| |
| The idea here is that the pin (^) operator does not only rebind existing |
| binding in the scope but also allows embedding basically any other expression |
| to match against the given part of the pattern. Normal pattern matching is |
| also supported and both can be mixed. The only caveat so far is that when |
| normal patterns fail, only they are listed in the error even if there are |
| potentially failing expressions. However, once the normal pattern is fixed, |
| they surface. |
| |
| Currently, the following expressions can be pinned: |
| |
| * `any(:atom)` |
| * `any(:string)` |
| * `any(:binary)` |
| * `any(:integer)` |
| * `any(:pos_integer)` |
| * `any(:number)` |
| * `any(:float)` |
| * `any(:boolean)` |
| * `any(:map)` |
| * `any(:list)` |
| * `any(:tuple)` |
| * `any(:iso8601_date)` |
| * `any(:iso8601_datetime)` |
| * `any(:iso8601_naive_datetime)` |
| * all above variants of any with a one argument predicate function accepting |
| value and returning a boolean, like: `any(:integer, & &1 > 20)` |
| * a special case of `any(:string, ~r/regex pattern/)` checking that value is |
| a string and matches a pattern |
| * shorthand version of the above, `~r/regex pattern/` |
| * any artibrary one argument function returning a boolean, like `&is_float/1` |
| or `&(&1 < 40 or &1 > 300)` |
| * exactly(expression) where expression is compared using equality, so that can |
| enforce full equality inside a pattern, like: `exactly(%{foo: 2})` which will |
| fail if the value is something like `%{foo: 2, other: "something}` |
| * any other arbitrary expression which is compared the way as if it was wrapped |
| with `exactly()`; this allows "interpolating" values from schemas and maps without |
| rebinding like `user.id` (instead of having to rebind to `user_id` first) |
| |
| There's also a special pin type, `strict_map(...)` which can wrap around any map |
| in the pattern. It's enforcing that the pattern has enumerated all the keys |
| present in the respective map in the pattern matched value. All the above mentioned |
| pin expressions can be also used inside `strict_map(...)` and `strict_map()` pins |
| can be nested. |
| |
| Usage example: |
| |
| n = %{z: 2} |
| |
| assert_matches %{ |
| a: ^any(:integer, &(&1 > 2)), |
| b: ^any(:string, ~r/baz/), |
| d: [_ | _], |
| e: ^~r/invalid/, |
| f: ^n.z, |
| g: ^(&is_float/1), |
| h: ^exactly(%{foo: :bar}) |
| } = %{ |
| a: 1, |
| b: "twofer", |
| c: :other, |
| d: [1, 2, 3], |
| e: "another string", |
| f: 1, |
| g: 4.2, |
| h: %{foo: :bar, other: "stuff"} |
| } |
| """ |
|
|
| @doc @moduledoc |
| defmacro assert_matches({:=, meta, [pattern, value]}) do |
| {base_strict_pattern, strict_vars} = build_base_strict_pattern(pattern) |
|
|
| base_strict_pattern = clear_bindings_except(base_strict_pattern, :match_var) |
|
|
| strict_patterns = |
| Enum.map(strict_vars, fn strict_var -> |
| build_strict_pattern(base_strict_pattern, strict_var) |
| end) |
|
|
| strict_pattern_matches = |
| Enum.map(strict_patterns, fn {strict_pattern, _} -> |
| quote do |
| assert unquote(strict_pattern) = unquote(value) |
| end |
| end) |
|
|
| strict_pattern_checks = |
| Enum.map(strict_patterns, fn {strict_pattern, [{strict_var, map_pattern_keys}]} -> |
| build_strict_pattern_check(strict_pattern, strict_var, map_pattern_keys, pattern, value) |
| end) |
|
|
| {var_pattern, pins} = build_var_pattern(pattern) |
|
|
| clean_pattern = |
| Enum.reduce(pins, var_pattern, fn {var, _predicate}, pattern -> |
| Macro.postwalk(pattern, fn |
| ^var -> {:_, [], __MODULE__} |
| other -> other |
| end) |
| end) |
|
|
| var_pattern = clear_bindings_except(var_pattern, :assert_match) |
|
|
| predicate_pattern = build_predicate_pattern(var_pattern, pins) |
|
|
| quote do |
| value = unquote(value) |
| assert unquote(clean_pattern) = value |
| unquote(strict_pattern_matches) |
| unquote(strict_pattern_checks) |
|
|
| assert unquote(var_pattern) = value |
|
|
| if unquote(length(pins) > 0) do |
| {errors?, predicate_pattern} = unquote(predicate_pattern) |
|
|
| if errors? do |
| raise ExUnit.AssertionError, |
| message: "match (=) failed", |
| left: predicate_pattern, |
| right: value, |
| expr: |
| {:assert_matches, unquote(meta), |
| [{:=, [], [unquote(Macro.escape(pattern)), Macro.escape(value)]}]}, |
| context: {:match, []} |
| end |
| end |
| end |
| end |
|
|
| defp build_base_strict_pattern(pattern) do |
| Macro.postwalk(pattern, [], fn |
| {:^, _, [{:strict_map, _, _}]} = pin, acc -> |
| pinned_var = Macro.unique_var(:match, __MODULE__) |
|
|
| pin = Macro.update_meta(pin, &Keyword.put(&1, :match_var, pinned_var)) |
|
|
| {pin, [pinned_var | acc]} |
|
|
| {:^, _, _}, acc -> |
| {{:_, [], __MODULE__}, acc} |
|
|
| other, acc -> |
| {other, acc} |
| end) |
| end |
|
|
| defp build_strict_pattern(base_strict_pattern, strict_var) do |
| Macro.postwalk(base_strict_pattern, [], fn |
| {:^, meta, [{:strict_map, _, [pinned]}]}, acc -> |
| if meta[:match_var] == strict_var do |
| {:%{}, _, map_pattern_values} = pinned |
| map_pattern_keys = map_pattern_values |> Enum.map(&elem(&1, 0)) |> Enum.sort() |
| {strict_var, [{strict_var, map_pattern_keys} | acc]} |
| else |
| { |
| Macro.postwalk(pinned, fn |
| {:^, _, _} -> |
| {:_, [], __MODULE__} |
|
|
| other -> |
| other |
| end), |
| acc |
| } |
| end |
|
|
| other, acc -> |
| {other, acc} |
| end) |
| end |
|
|
| defp build_strict_pattern_check(strict_pattern, strict_var, map_pattern_keys, pattern, value) do |
| quote bind_quoted: [ |
| pattern: Macro.escape(pattern), |
| value: value, |
| strict_pattern: Macro.escape(strict_pattern), |
| var: strict_var, |
| escaped_var: Macro.escape(strict_var), |
| pattern_keys: map_pattern_keys |
| ] do |
| var_keys = var |> Map.keys() |> Enum.sort() |
|
|
| if pattern_keys != var_keys do |
| missing_keys = var_keys -- pattern_keys |
|
|
| map_pattern_values = |
| pattern_keys |
| |> Enum.map(&{&1, {:_, [], __MODULE__}}) |
| |> Enum.concat(Enum.map(missing_keys, &{&1, :_MISSING_KEY__})) |
|
|
| error_pattern = |
| Macro.postwalk(strict_pattern, fn |
| ^escaped_var -> {:%{}, [], map_pattern_values} |
| other -> other |
| end) |
|
|
| raise ExUnit.AssertionError, |
| message: "match (=) failed", |
| left: error_pattern, |
| right: value, |
| expr: {:assert_matches, [], [{:=, [], [pattern, Macro.escape(value)]}]}, |
| context: {:match, []} |
| end |
| end |
| end |
|
|
| defp build_predicate_pattern(var_pattern, pins) do |
| quote bind_quoted: [ |
| var_pattern: Macro.escape(var_pattern), |
| escaped_pins: Macro.escape(pins), |
| pins: pins |
| ] do |
| escaped_pins |
| |> Enum.zip(pins) |
| |> Enum.reduce({false, var_pattern}, fn {{escaped_var, escaped_predicate}, {var, predicate}}, |
| {errors?, pattern} -> |
| result = |
| if is_function(predicate, 1) do |
| not predicate.(var) |
| else |
| predicate != var |
| end |
|
|
| if result do |
| escaped_predicate = Plausible.AssertMatches.Internal.strip_prefix(escaped_predicate) |
|
|
| {true, |
| Macro.postwalk(pattern, fn |
| ^escaped_var -> escaped_predicate |
| other -> other |
| end)} |
| else |
| {errors?, |
| Macro.postwalk(pattern, fn |
| ^escaped_var -> {:_, [], __MODULE__} |
| other -> other |
| end)} |
| end |
| end) |
| end |
| end |
|
|
| defp build_var_pattern(pattern) do |
| Macro.postwalk(pattern, [], fn |
| {:^, _meta, [{pinned, _, module}]} = normal_pin, acc |
| when is_atom(pinned) and is_atom(module) -> |
| {normal_pin, acc} |
|
|
| {:^, _meta, [{:strict_map, _, [pinned]}]}, acc -> |
| {pinned, acc} |
|
|
| {:^, _meta, [pinned]}, acc -> |
| pinned = Plausible.AssertMatches.Internal.transform_predicate(pinned) |
|
|
| pinned_var = |
| Macro.unique_var(:match, __MODULE__) |
| |> Macro.update_meta(&Keyword.put(&1, :assert_match, true)) |
|
|
| {pinned_var, [{pinned_var, pinned} | acc]} |
|
|
| other, acc -> |
| {other, acc} |
| end) |
| end |
|
|
| defp clear_bindings_except(pattern, except_meta) do |
| pattern |
| |> Macro.postwalk(fn |
| {:^, _, [{name, meta, module}]} = pin when is_atom(name) and is_atom(module) -> |
| if meta[except_meta] do |
| pin |
| else |
| {:_, [], __MODULE__} |
| end |
|
|
| other -> |
| other |
| end) |
| |> Macro.postwalk(fn |
| {name, meta, module} = var when is_atom(name) and is_atom(module) -> |
| if meta[except_meta] do |
| var |
| else |
| {:_, [], __MODULE__} |
| end |
|
|
| other -> |
| other |
| end) |
| end |
|
|
| defmodule Internal do |
| @moduledoc false |
|
|
| def transform_predicate({:any, _, [value]}) do |
| quote do |
| Plausible.AssertMatches.Internal.any(unquote(value)) |
| end |
| end |
|
|
| def transform_predicate({:exactly, _, [value]}) do |
| quote do |
| Plausible.AssertMatches.Internal.exactly(unquote(value)) |
| end |
| end |
|
|
| def transform_predicate({:any, _, [value, extra_predicate]}) do |
| quote do |
| Plausible.AssertMatches.Internal.any( |
| unquote(value), |
| unquote(extra_predicate) |
| ) |
| end |
| end |
|
|
| def transform_predicate({:sigil_r, _, _} = regex) do |
| quote do |
| Plausible.AssertMatches.Internal.regex(unquote(regex)) |
| end |
| end |
|
|
| def transform_predicate(other), do: other |
|
|
| def strip_prefix({{:., _, [_prefix, f]}, _, args}) when f in [:any, :regex, :exactly] do |
| {f, [], args} |
| end |
|
|
| def strip_prefix(predicate) do |
| predicate |
| end |
|
|
| def any(:atom), do: &is_atom/1 |
| def any(:string), do: &is_binary/1 |
| def any(:binary), do: &is_binary/1 |
| def any(:integer), do: &is_integer/1 |
| def any(:number), do: &is_number/1 |
| def any(:float), do: &is_float/1 |
| def any(:boolean), do: &is_boolean/1 |
| def any(:map), do: &is_map/1 |
| def any(:list), do: &is_list/1 |
| def any(:tuple), do: &is_tuple/1 |
|
|
| def any(:pos_integer) do |
| fn value -> |
| is_integer(value) and value > 0 |
| end |
| end |
|
|
| def any(:iso8601_date) do |
| fn value -> |
| case Date.from_iso8601(value) do |
| {:ok, _} -> true |
| _ -> false |
| end |
| end |
| end |
|
|
| def any(:iso8601_datetime) do |
| fn value -> |
| case DateTime.from_iso8601(value) do |
| {:ok, _, _} -> true |
| _ -> false |
| end |
| end |
| end |
|
|
| def any(:iso8601_naive_datetime) do |
| fn value -> |
| case NaiveDateTime.from_iso8601(value) do |
| {:ok, _} -> true |
| _ -> false |
| end |
| end |
| end |
|
|
| def any(:string, %Regex{} = regex) do |
| fn value -> |
| any(:string).(value) and regex(regex).(value) |
| end |
| end |
|
|
| def any(type, predicate_fn) when is_function(predicate_fn, 1) do |
| fn value -> |
| any(type).(value) and predicate_fn.(value) |
| end |
| end |
|
|
| def regex(regex) do |
| fn value -> |
| String.match?(value, regex) |
| end |
| end |
|
|
| def exactly(expr) do |
| fn value -> |
| value == expr |
| end |
| end |
| end |
| end |
|
|