lib/snap.ex

defmodule Snap do
  @moduledoc """
  Provides macros for instant snapshot testing, that is, macros that automatically amend
  the source code of the test file at their call site with the evaluated value of the expression
  under test.
  """

  defmodule UnexpectedLackOfException do
    @moduledoc false
    defexception message: "Expected exception but nothing was raised"
  end

  @doc """
  With two arguments, `snap_assert` is equivalent to a regular `assert`
  """
  defmacro snap_assert(arg1, arg2) do
    quote bind_quoted: [arg1: arg1, arg2: arg2] do
      assert arg1 == arg2
    end
  end

  @doc """
  Modifies the source code of the test file at the call site, adding the evaluated value of `arg` as the second argument.
  """
  defmacro snap_assert(arg) do
    # TODO: There is a problem with duplication in the generated code; seems to be no way to use
    # a private macro to deduplicate (as it has to generate code using variables from surrounding context).
    # Another possibility is to try generating macros like `snap_assert` with another macro.
    # Macro call line can be obtained via `__CALLER__.line`

    quote do
      result = unquote(arg)
      line = __ENV__.line
      path = __ENV__.file

      :global.set_lock({path, self()})
      source = File.read!(path)

      {_quoted, patches} =
        source
        |> Sourceror.parse_string!()
        |> Macro.postwalk([], fn
          {:snap_assert = macro_match, meta, [matched_arg]} = quoted_match, patches ->
            Snap.__patched__(quoted_match, patches, __ENV__.line, result, flip_args?: false)

          {{:., _, [{:__aliases__, _, [:Snap]}, :snap_assert]} = macro_match, meta, [matched_arg]} = quoted_match,
          patches ->
            Snap.__patched__(quoted_match, patches, __ENV__.line, result, flip_args?: false)

          quoted, patches ->
            {quoted, patches}
        end)

      output = Sourceror.patch_string(source, patches)

      File.write!(path, output, [:sync])
      :global.del_lock({path, self()})
    end
  end

  @doc """
  With two arguments, `snap_assert_raise` is equivalent to a regular `assert_raise`
  """
  defmacro snap_assert_raise(arg1, arg2) do
    quote bind_quoted: [arg1: arg1, arg2: arg2] do
      assert_raise arg1, arg2
    end
  end

  @doc """
  Modifies the source code of the test file at the call site, adding the exception produced by `fun`
  as the first argument (or `Snap.UnexpectedLackOfException` if no exception is produced).
  """
  defmacro snap_assert_raise(fun) do
    quote do
      result =
        try do
          apply(unquote(fun), [])
          raise UnexpectedLackOfException
        rescue
          error ->
            error.__struct__
        end

      line = __ENV__.line
      path = __ENV__.file

      :global.set_lock({path, self()})
      source = File.read!(path)

      {_quoted, patches} =
        source
        |> Sourceror.parse_string!()
        |> Macro.postwalk([], fn
          {:snap_assert_raise = macro_match, meta, [matched_arg]} = quoted_match, patches ->
            Snap.__patched__(quoted_match, patches, __ENV__.line, result, flip_args?: true)

          {{:., _, [{:__aliases__, _, [:Snap]}, :snap_assert_raise]} = macro_match, meta, [matched_arg]} = quoted_match,
          patches ->
            Snap.__patched__(quoted_match, patches, __ENV__.line, result, flip_args?: true)

          quoted, patches ->
            {quoted, patches}
        end)

      output = Sourceror.patch_string(source, patches)

      File.write!(path, output, [:sync])
      :global.del_lock({path, self()})
    end
  end

  # There appears to be no way to call from a macro another private macro which uses arguments
  # from the surrounding code, hence I have to rely on calling a public function (a private function
  # of course isn't an option either)
  def __patched__({macro_match, meta, [matched_arg]} = quoted, patches, line, result, flip_args?: flip_args?) do
    if Enum.member?(meta, {:line, line}) do
      range = Sourceror.get_range(quoted)

      replacement =
        {macro_match, meta, if(flip_args?, do: [result, matched_arg], else: [matched_arg, result])}
        |> Sourceror.to_string()

      patch = %{range: range, change: replacement}
      {quoted, [patch | patches]}
    else
      IO.puts("Line didn't match, no replacement generated")
      {quoted, patches}
    end
  end
end