diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..600c121 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +direnv_load mise direnv exec diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0082015..1135daa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,31 +10,6 @@ permissions: contents: read jobs: - build-otp-23: - name: Build and test on OTP 23 - runs-on: ubuntu-20.04 - strategy: - matrix: - elixir_version: - - "1.10" - - "1.11" - - "1.12" - - "1.13" - steps: - - uses: actions/checkout@v3 - - uses: erlef/setup-beam@v1 - with: - elixir-version: ${{ matrix.elixir_version }} - otp-version: "23" - - uses: actions/cache@v3 - with: - path: deps - key: 23-${{ matrix.elixir_version }}-${{ hashFiles('**/mix.lock') }} - restore-keys: | - 23-${{ matrix.elixir_version }}- - 23- - - run: mix deps.get - - run: mix test build-otp-24: name: Build and test on OTP 24 runs-on: ubuntu-20.04 @@ -92,6 +67,7 @@ jobs: elixir_version: - "1.14" - "1.15" + - "1.16" steps: - uses: actions/checkout@v3 - uses: erlef/setup-beam@v1 @@ -107,3 +83,26 @@ jobs: 26- - run: mix deps.get - run: mix test + build-otp-27: + name: Build and test on OTP 27 + runs-on: ubuntu-22.04 + strategy: + matrix: + elixir_version: + - "1.17" + - "1.18" + steps: + - uses: actions/checkout@v3 + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir_version }} + otp-version: "27" + - uses: actions/cache@v3 + with: + path: deps + key: 27-${{ matrix.elixir_version }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + 27-${{ matrix.elixir_version }}- + 27- + - run: mix deps.get + - run: mix test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4dc35d8..e360284 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,8 +16,8 @@ jobs: - uses: actions/checkout@v3 - uses: erlef/setup-beam@v1 with: - elixir-version: "1.14" - otp-version: "24" + elixir-version: "1.18" + otp-version: "27" - run: mix deps.get - run: mix test - run: mix hex.publish --yes diff --git a/lib/assert_match.ex b/lib/assert_match.ex index e271af8..2117615 100644 --- a/lib/assert_match.ex +++ b/lib/assert_match.ex @@ -6,11 +6,19 @@ defmodule AssertMatch do @doc """ Pipe-friendly equality/matching assertion. - Performs: + Performs `assert/1` with: - `=~` for Regex patterns + - `match?/2` for patterns with guards - `=` for other patterns + For the third variant, it can utilize bindings inside the pattern from outside! + + %{key: 1} + |> assert_match(%{key: value}) + + assert value == 1 + ## Extraction of pinned function calls Elixir's `^/1` (pin operator) usually does not allow runtime function calls, @@ -33,7 +41,27 @@ defmodule AssertMatch do }) You cannot nest pinned expressions. See test/assert_match_test.exs for more usages. + + ## Guards + + Guards are supported, but with limitations: if guards are used, bindings inside patterns cannot be used from outside. + + %{key: 1} + |> assert_match(map when is_map(map)) + # => Passes + + assert map == %{key: 1} + # => error: undefined variable "map" + + Related: bindings inside patterns that are NOT used in the guards are warned as unused. + + %{key: 1} + |> assert_match(%{key: value} = map when not is_map_key(map, :nonkey)) + # => warning: variable "value" is unused (if the variable is not meant to be used, prefix it with an underscore) + + This is a relatively new limitation introduced in Elixir 1.18, as a side-effect of [this change](https://github.com/elixir-lang/elixir/pull/13817). """ + @spec assert_match(any, Macro.t()) :: any defmacro assert_match(subject, pattern) do case pattern do falsy when falsy == nil or falsy == false -> @@ -55,11 +83,23 @@ defmodule AssertMatch do {matchable_ast, extracted_tmpvars} = extract_pinned_function_calls_to_variables(pattern) # Notice pattern is at left-hand side # In Elixir 1.10+, match assertions can display rich diffs for various subject-pattern combinations - quote do - right = unquote(subject) - unquote(tmpvar_definitions(extracted_tmpvars)) - ExUnit.Assertions.assert(unquote(matchable_ast) = right) - right + case matchable_ast do + {:when, _location, _branches} = ast_with_guard -> + # After Elixir 1.18, patterns with guards require special treatment. cf. https://github.com/elixir-lang/elixir/pull/13817 + quote do + right = unquote(subject) + unquote(tmpvar_definitions(extracted_tmpvars)) + ExUnit.Assertions.assert(match?(unquote(ast_with_guard), right)) + right + end + + _otherwise -> + quote do + right = unquote(subject) + unquote(tmpvar_definitions(extracted_tmpvars)) + ExUnit.Assertions.assert(unquote(matchable_ast) = right) + right + end end end end diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..60d5dec --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +elixir = "1.18.2-otp-27" +erlang = "27.2.1" diff --git a/mix.exs b/mix.exs index 46ebb8a..009bc7d 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,7 @@ defmodule AssertMatch.MixProject do app: :assert_match, description: "Leverages pattern matching & pipeline in Elixir tests by pipe-friendly `assert_match/2`", - version: "1.0.0", + version: "1.1.0", elixir: "~> 1.10", package: package(), deps: [{:ex_doc, "~> 0.27", only: :dev, runtime: false}] diff --git a/test/assert_match_test.exs b/test/assert_match_test.exs index a1fca08..0298fee 100644 --- a/test/assert_match_test.exs +++ b/test/assert_match_test.exs @@ -18,7 +18,7 @@ defmodule AssertMatchTest do end test "should work with regex" do - assert_match("prefix match", ~R/prefix/) + assert_match("prefix match", ~r/prefix/) assert_match("prefix match", ~r/#{"prefix"}/) end @@ -30,6 +30,13 @@ defmodule AssertMatchTest do assert_match([1, 2], [_ | _]) end + test "should work with guards" do + assert_match(%{key: 1}, map when is_map_key(map, :key)) + + # guard works with assert_match/2 but has limitation: bindings inside patterns cannot be used from outside + assert_match(%{key: 1}, %{key: _value} = map when not is_map_key(map, :nonkey)) + end + test "should work with incomplete struct patterns" do # As a value, %Date{year: 2018} raises since [:month, :day] are also enforced. assert_match(~D[2018-01-01], %Date{year: 2018})