lib/cabbage/feature.ex

defmodule Cabbage.Feature do
  @moduledoc """
  An extension on ExUnit to be able to execute feature files.

  ## Configuration

  In `config/test.exs`

      config :cabbage,
        # Default is "test/features/"
        features: "my/path/to/features/"
        # Default is []
        global_tags: :integration

  - `features` - Allows you to specify the location of your feature files. They can be anywhere, but typically are located within the test folder.
  - `global_tags` - Allow you to specify ex unit tag assigned to all cabbage generated tests

  ## Features

  Given a feature file, create a corresponding feature module which references it. Heres an example:

      defmodule MyApp.SomeFeatureTest do
        use Cabbage.Feature, file: "some_feature.feature"

        defgiven ~r/I am given a given statement/, _matched_data, _current_state do
          assert 1 + 1 == 2
          {:ok, %{new: :state}}
        end

        defwhen ~r/I when execute it/, _matched_data, _current_state do
          # Nothing to do, don't need to return anything if we don't want to
          nil
        end

        defthen ~r/everything is ok/, _matched_data, _current_state do
          assert true
        end
      end

  This translates loosely into:

      defmodule MyApp.SomeFeatureTest do
        use ExUnit.Case

        test "The name of the scenario here" do
          assert 1 + 1 == 2
          nil
          assert true
        end
      end

  ### Extracting Matched Data

  You'll likely have data within your feature statements which you want to extract. The second parameter to each of `defgiven/4`, `defwhen/4` and `defthen/4` is a pattern in which specifies what you want to call the matched data, provided as a map.

  For example, if you want to match on a number:

      # NOTICE THE `number` VARIABLE IS STILL A STRING!!
      defgiven ~r/^there (is|are) (?<number>\d+) widget(s?)$/, %{number: number}, _state do
        assert String.to_integer(number) >= 1
      end

  For every named capture, you'll have a key as an atom in the second parameter. You can then use those variables you create within your block.

  ### Modifying State

  You'll likely have to keep track of some state in between statements. The third parameter to each of `defgiven/4`, `defwhen/4` and `defthen/4` is a pattern in which specifies what you want to call your state in the same way that the `ExUnit.Case.test/3` macro works.

  You can setup initial state using plain ExUnit `setup/1` and `setup_all/1`. Whatever state is provided via the `test/3` macro will be your initial state.

  To update the state, simply return `{:ok, %{new: :state}}`. Note that a `Map.merge/2` will be performed for you so only have to specify the keys you want to update. For this reason, only a map is allowed as state.

  Heres an example modifying state:

      defwhen ~r/^I am an admin$/, _, %{user: user} do
        {:ok, %{user: User.promote_to_admin(user)}}
      end

  All other statements do not need to return (and should be careful not to!) the `{:ok, state}` pattern.

  ### Organizing Features

  You may want to reuse several statements you create, especially ones that deal with global logic like users and logging in.

  Feature modules can be created without referencing a file. This makes them do nothing except hold translations between steps in a scenario and test code to be included into a test. These modules must be compiled prior to running the test suite, so for that reason you must add them to the `elixirc_paths` in your `mix.exs` file, like so:

      defmodule MyApp.Mixfile do
        use Mix.Project

        def project do
          [
            app: :my_app,
            ... # Add this to your project function
            elixirc_paths: elixirc_paths(Mix.env),
            ...
          ]
        end

        # Specifies which paths to compile per environment.
        defp elixirc_paths(:test), do: ["lib", "test/support"]
        defp elixirc_paths(_),     do: ["lib"]

        ...
      end

  If you're using Phoenix, this should already be setup for you. Simply place a file like the following into `test/support`.

      defmodule MyApp.GlobalFeatures do
        use Cabbage.Feature

        # Write your `defgiven/4`, `defthen/4` and `defwhen/4`s here
      end

  Then inside the test file (the .exs one) add a `import_feature MyApp.GlobalFeatures` line after the `use Cabbage.Feature` line lke so:

      defmodule MyApp.SomeFeatureTest do
        use Cabbage.Feature, file: "some_feature.feature"
        import_feature MyApp.GlobalFeatures

        # Omitted the rest
      end

  Keep in mind that if you'd like to be more explicit about what you bring into your test, you can use the macros `import_steps/1` and `import_tags/1`. This will allow you to be more selective about whats getting included into your integration tests. The `import_feature/1` macro simply calls both the `import_steps/1` and `import_tags/1` macros.
  """
  import Cabbage.Feature.Helpers

  alias Cabbage.Feature.{Loader, MissingStepError}

  @feature_options [:file, :template]
  defmacro __using__(options) do
    has_assigned_feature = !match?(nil, options[:file])

    Module.register_attribute(__CALLER__.module, :steps, accumulate: true)
    Module.register_attribute(__CALLER__.module, :tags, accumulate: true)

    quote do
      unquote(prepare_executable_feature(has_assigned_feature, options))

      @before_compile {unquote(__MODULE__), :expose_metadata}
      import unquote(__MODULE__)
      require Logger

      unquote(load_features(has_assigned_feature, options))
    end
  end

  defp prepare_executable_feature(false, _options), do: nil

  defp prepare_executable_feature(true, options) do
    {_options, template_options} = Keyword.split(options, @feature_options)

    quote do
      @before_compile unquote(__MODULE__)
      use unquote(options[:template] || ExUnit.Case), unquote(template_options)
    end
  end

  defp load_features(false, _options), do: nil

  defp load_features(true, options) do
    quote do
      @feature Loader.load_from_file(unquote(options[:file]))
      @scenarios @feature.scenarios
    end
  end

  defmacro expose_metadata(env) do
    steps = Module.get_attribute(env.module, :steps) || []
    tags = Module.get_attribute(env.module, :tags) || []

    quote generated: true do
      def raw_steps() do
        unquote(Macro.escape(steps))
      end

      def raw_tags() do
        unquote(Macro.escape(tags))
      end
    end
  end

  defmacro __before_compile__(env) do
    scenarios = Module.get_attribute(env.module, :scenarios) || []
    steps = Module.get_attribute(env.module, :steps) || []
    tags = Module.get_attribute(env.module, :tags) || []

    scenarios
    |> Enum.map(fn scenario ->
      scenario =
        Map.put(
          scenario,
          :tags,
          Cabbage.global_tags() ++ List.wrap(Module.get_attribute(env.module, :moduletag)) ++ scenario.tags
        )

      quote bind_quoted: [
              scenario: Macro.escape(scenario),
              tags: Macro.escape(tags),
              steps: Macro.escape(steps)
            ],
            line: scenario.line do
        describe scenario.name do
          setup context do
            for tag <- unquote(scenario.tags) do
              case tag do
                {tag, _value} ->
                  Cabbage.Feature.Helpers.run_tag(
                    unquote(Macro.escape(tags)),
                    tag,
                    __MODULE__,
                    unquote(scenario.name)
                  )

                tag ->
                  Cabbage.Feature.Helpers.run_tag(
                    unquote(Macro.escape(tags)),
                    tag,
                    __MODULE__,
                    unquote(scenario.name)
                  )
              end
            end

            {:ok,
             Map.merge(
               Cabbage.Feature.Helpers.fetch_state(unquote(scenario.name), __MODULE__),
               context || %{}
             )}
          end

          tags = Cabbage.Feature.Helpers.map_tags(scenario.tags) || []

          name =
            ExUnit.Case.register_test(
              __ENV__,
              :scenario,
              scenario.name,
              tags
            )

          def unquote(name)(exunit_state) do
            Cabbage.Feature.Helpers.start_state(unquote(scenario.name), __MODULE__, exunit_state)

            unquote(Enum.map(scenario.steps, &compile_step(&1, steps, scenario.name)))
          end
        end
      end
    end)
  end

  def compile_step(step, steps, scenario_name) when is_list(steps) do
    step_type = step.keyword

    step
    |> find_implementation_of_step(steps)
    |> compile(step, step_type, scenario_name)
  end

  defp compile(
         {:{}, _, [regex, vars, state_pattern, block, metadata]},
         step,
         step_type,
         scenario_name
       ) do
    {regex, _} = Code.eval_quoted(regex)

    named_vars =
      extract_named_vars(regex, step.text)
      |> Map.merge(%{table: step.table_data, doc_string: step.doc_string})

    quote generated: true do
      with {_type, unquote(vars)} <- {:variables, unquote(Macro.escape(named_vars))},
           {_type, state = unquote(state_pattern)} <-
             {:state, Cabbage.Feature.Helpers.fetch_state(unquote(scenario_name), __MODULE__)} do
        new_state =
          case unquote(block) do
            {:ok, new_state} -> Map.merge(state, new_state)
            _ -> state
          end

        Cabbage.Feature.Helpers.update_state(unquote(scenario_name), __MODULE__, fn _ ->
          new_state
        end)

        Logger.info([
          "\t\t",
          IO.ANSI.cyan(),
          unquote(step_type),
          " ",
          IO.ANSI.green(),
          unquote(step.text)
        ])
      else
        {type, state} ->
          metadata = unquote(Macro.escape(metadata))

          reraise """
                  ** (MatchError) Failure to match #{type} of #{inspect(Cabbage.Feature.Helpers.remove_hidden_state(state))}
                  Pattern: #{unquote(Macro.to_string(state_pattern))}
                  """,
                  Cabbage.Feature.Helpers.stacktrace(__MODULE__, metadata)
      end
    end
  end

  defp compile(_, step, step_type, _scenario_name) do
    extra_vars = %{table: step.table_data, doc_string: step.doc_string}

    raise MissingStepError, step_text: step.text, step_type: step_type, extra_vars: extra_vars
  end

  defp find_implementation_of_step(step, steps) do
    Enum.find(steps, fn {:{}, _, [r, _, _, _, _]} ->
      step.text =~ r |> Code.eval_quoted() |> elem(0)
    end)
  end

  defp extract_named_vars(regex, step_text) do
    regex
    |> Regex.named_captures(step_text)
    |> Enum.map(fn {k, v} -> {String.to_atom(k), v} end)
    |> Enum.into(%{})
  end

  @doc """
  Brings in all the functionality available from the supplied module. Module must `use Cabbage.Feature` (with or without a `:file`).

  Same as calling both `import_steps/1` and `import_tags/1`.
  """
  defmacro import_feature(module) do
    quote do
      import_steps(unquote(module))
      import_tags(unquote(module))
    end
  end

  @doc """
  Brings in all the step definitions from the supplied module. Module must `use Cabbage.Feature` (with or without a `:file`).
  """
  defmacro import_steps(module) do
    quote do
      if Code.ensure_compiled(unquote(module)) do
        for step <- unquote(module).raw_steps() do
          Module.put_attribute(__MODULE__, :steps, step)
        end
      end
    end
  end

  @doc """
  Brings in all the tag definitions from the supplied module. Module must `use Cabbage.Feature` (with or without a `:file`).
  """
  defmacro import_tags(module) do
    quote do
      if Code.ensure_compiled(unquote(module)) do
        for {name, block} <- unquote(module).raw_tags() do
          Cabbage.Feature.Helpers.add_tag(__MODULE__, name, block)
        end
      end
    end
  end

  defmacro defgiven(regex, vars, state, do: block) do
    add_step(__CALLER__.module, regex, vars, state, block, metadata(__CALLER__, :defgiven))
  end

  defmacro defwhen(regex, vars, state, do: block) do
    add_step(__CALLER__.module, regex, vars, state, block, metadata(__CALLER__, :defwhen))
  end

  defmacro defthen(regex, vars, state, do: block) do
    add_step(__CALLER__.module, regex, vars, state, block, metadata(__CALLER__, :defthen))
  end

  @doc """
  Add an ExUnit `setup/1` callback that only fires for the scenarios that are tagged. Can be
  used inside of `Cabbage.Feature`s that don't relate to a file and then imported with `import_feature/1`.

  Example usage:

      defmodule MyTest do
        use Cabbage.Feature

        tag @some_tag do
          IO.puts "Do this before the @some_tag scenario"
          on_exit fn ->
            IO.puts "Do this after the @some_tag scenario"
          end
        end
      end
  """
  defmacro tag(tag, do: block) do
    add_tag(__CALLER__.module, Macro.to_string(tag) |> String.replace(~r/\s*/, ""), block)
  end
end