lib/ash/dsl/extension.ex

defmodule Ash.Dsl.Extension do
  @moduledoc """
  An extension to the Ash DSL.

  This allows configuring custom DSL components, whose configurations
  can then be read back. This guide is still a work in progress, but should
  serve as a decent example of what is possible. Open issues on Github if you
  have any issues/something is unclear.

  The example at the bottom shows how you might build a (not very contextually
  relevant) DSL extension that would be used like so:

      defmodule MyApp.MyResource do
        use Ash.Resource,
          extensions: [MyApp.CarExtension]

        cars do
          car :mazda, "6", trim: :touring
          car :toyota, "corolla"
        end
      end

  The extension:

      defmodule MyApp.CarExtension do
        @car_schema [
          make: [
            type: :atom,
            required: true,
            doc: "The make of the car"
          ],
          model: [
            type: :atom,
            required: true,
            doc: "The model of the car"
          ],
          type: [
            type: :atom,
            required: true,
            doc: "The type of the car",
            default: :sedan
          ]
        ]

        @car %Ash.Dsl.Entity{
          name: :car,
          describe: "Adds a car",
          examples: [
            "car :mazda, \"6\""
          ],
          target: MyApp.Car,
          args: [:make, :model],
          schema: @car_schema
        }

        @cars %Ash.Dsl.Section{
          name: :cars, # The DSL constructor will be `cars`
          describe: \"\"\"
          Configure what cars are available.

          More, deeper explanation. Always have a short one liner explanation,
          an empty line, and then a longer explanation.
          \"\"\",
          entities: [
            @car # See `Ash.Dsl.Entity` docs
          ],
          schema: [
            default_manufacturer: [
              type: :atom,
              doc: "The default manufacturer"
            ]
          ]
        }

        use Ash.Dsl.Extension, sections: [@cars]
      end


  Often, we will need to do complex validation/validate based on the configuration
  of other resources. Due to the nature of building compile time DSLs, there are
  many restrictions around that process. To support these complex use cases, extensions
  can include `transformers` which can validate/transform the DSL state after all basic
  sections/entities have been created. See `Ash.Dsl.Transformer` for more information.
  Transformers are provided as an option to `use`, like so:

      use Ash.Dsl.Extension, sections: [@cars], transformers: [
        MyApp.Transformers.ValidateNoOverlappingMakesAndModels
      ]

  By default, the generated modules will have names like `__MODULE__.SectionName.EntityName`, and that could
  potentially conflict with modules you are defining, so you can specify the `module_prefix` option, which would allow
  you to prefix the modules with something like `__MODULE__.Dsl`, so that the module path generated might be something like
  `__MODULE__.Dsl.SectionName.EntityName`, and you could then have the entity struct be `__MODULE__.SectionName.EntityName`
  without conflicts.

  To expose the configuration of your DSL, define functions that use the
  helpers like `get_entities/2` and `get_opt/3`. For example:

      defmodule MyApp.Cars do
        def cars(resource) do
          Ash.Dsl.Extension.get_entities(resource, [:cars])
        end
      end

      MyApp.Cars.cars(MyResource)
      # [%MyApp.Car{...}, %MyApp.Car{...}]

  See the documentation for `Ash.Dsl.Section` and `Ash.Dsl.Entity` for more information
  """

  @callback sections() :: [Ash.Dsl.section()]
  @callback transformers() :: [module]

  defp dsl!(resource) do
    resource.ash_dsl_config()
  rescue
    _ in [UndefinedFunctionError, ArgumentError] ->
      try do
        Module.get_attribute(resource, :ash_dsl_config) || %{}
      rescue
        ArgumentError ->
          try do
            resource.ash_dsl_config()
          rescue
            _ ->
              reraise ArgumentError,
                      """
                      No such entity #{inspect(resource)} found.
                      """,
                      __STACKTRACE__
          end
      end
  end

  @doc "Get the entities configured for a given section"
  def get_entities(resource, path) do
    dsl!(resource)[path][:entities] || []
  end

  @doc "Get a value that was persisted while transforming or compiling the resource, e.g `:primary_key`"
  def get_persisted(resource, key, default \\ nil) do
    Map.get(dsl!(resource)[:persist] || %{}, key, default)
  end

  @doc """
  Get an option value for a section at a given path.

  Checks to see if it has been overridden via configuration.
  """
  def get_opt(resource, path, value, default, configurable? \\ false) do
    path = List.wrap(path)

    if configurable? do
      case get_opt_config(resource, path, value) do
        {:ok, value} ->
          value

        _ ->
          Keyword.get(dsl!(resource)[path][:opts] || [], value, default)
      end
    else
      Keyword.get(dsl!(resource)[path][:opts] || [], value, default)
    end
  end

  @doc """
  Generate a table of contents for a list of sections
  """
  def doc_index(sections, depth \\ 0) do
    sections
    |> Enum.flat_map(fn
      {_, entities} ->
        entities

      other ->
        [other]
    end)
    |> Enum.map_join("\n", fn
      section ->
        docs =
          if depth == 0 do
            String.duplicate(" ", depth + 1) <>
              "* [#{section.name}](#module-#{section.name})"
          else
            String.duplicate(" ", depth + 1) <> "* #{section.name}"
          end

        case List.wrap(section.entities) ++ List.wrap(Map.get(section, :sections)) do
          [] ->
            docs

          sections_and_entities ->
            docs <> "\n" <> doc_index(sections_and_entities, depth + 2)
        end
    end)
  end

  @doc """
  Generate documentation for a list of sections
  """
  def doc(sections, depth \\ 1) do
    Enum.map_join(sections, "\n\n", fn section ->
      String.duplicate("#", depth + 1) <>
        " " <>
        to_string(section.name) <> "\n\n" <> doc_section(section, depth)
    end)
  end

  def doc_section(section, depth \\ 1) do
    sections_and_entities = List.wrap(section.entities) ++ List.wrap(section.sections)

    table_of_contents =
      case sections_and_entities do
        [] ->
          ""

        sections_and_entities ->
          doc_index(sections_and_entities)
      end

    options = Ash.OptionsHelpers.docs(section.schema)

    examples =
      case section.examples do
        [] ->
          ""

        examples ->
          "Examples:\n" <> Enum.map_join(examples, &doc_example/1)
      end

    entities =
      Enum.map_join(section.entities, "\n\n", fn entity ->
        String.duplicate("#", depth + 2) <>
          " " <>
          to_string(entity.name) <>
          "\n\n" <>
          doc_entity(entity, depth + 2)
      end)

    sections =
      Enum.map_join(section.sections, "\n\n", fn section ->
        String.duplicate("#", depth + 2) <>
          " " <>
          to_string(section.name) <>
          "\n\n" <>
          doc_section(section, depth + 1)
      end)

    imports =
      case section.imports do
        [] ->
          ""

        mods ->
          "Imports:\n\n" <>
            Enum.map_join(mods, "\n", fn mod ->
              "* `#{inspect(mod)}`"
            end)
      end

    """
    #{section.describe}

    #{table_of_contents}

    #{examples}

    #{imports}

    ---

    #{options}

    #{entities}

    #{sections}
    """
  end

  def doc_entity(entity, depth \\ 1) do
    options = Ash.OptionsHelpers.docs(Keyword.drop(entity.schema, entity.hide))

    examples =
      case entity.examples do
        [] ->
          ""

        examples ->
          "Examples:\n" <> Enum.map_join(examples, &doc_example/1)
      end

    entities =
      Enum.flat_map(entity.entities, fn
        {_, entities} ->
          entities

        other ->
          [other]
      end)

    entities_doc =
      Enum.map_join(entities, "\n\n", fn entity ->
        String.duplicate("#", depth + 2) <>
          " " <>
          to_string(entity.name) <>
          "\n\n" <>
          doc_entity(entity, depth + 1)
      end)

    table_of_contents =
      case entities do
        [] ->
          ""

        entities ->
          doc_index(entities)
      end

    """
    #{entity.describe}

    #{table_of_contents}

    Introspection Target:

    `#{inspect(entity.target)}`

    #{examples}

    #{options}

    #{entities_doc}
    """
  end

  def get_opt_config(resource, path, value) do
    with otp_app when not is_nil(otp_app) <- get_persisted(resource, :otp_app),
         {:ok, config} <- Application.fetch_env(otp_app, resource) do
      path
      |> List.wrap()
      |> Kernel.++([value])
      |> Enum.reduce_while({:ok, config}, fn key, {:ok, config} ->
        if Keyword.keyword?(config) do
          case Keyword.fetch(config, key) do
            {:ok, value} -> {:cont, {:ok, value}}
            :error -> {:halt, :error}
          end
        else
          {:halt, :error}
        end
      end)
    end
  end

  defp doc_example({description, example}) when is_binary(description) and is_binary(example) do
    """
    #{description}
    ```
    #{example}
    ```
    """
  end

  defp doc_example(example) when is_binary(example) do
    """
    ```
    #{example}
    ```
    """
  end

  @doc false
  defmacro __using__(opts) do
    quote bind_quoted: [
            sections: opts[:sections] || [],
            transformers: opts[:transformers] || [],
            module_prefix: opts[:module_prefix]
          ] do
      alias Ash.Dsl.Extension

      @behaviour Extension
      Extension.build(__MODULE__, module_prefix, sections)
      @_sections sections
      @_transformers transformers

      @doc false
      def sections, do: set_docs(@_sections)

      defp set_docs(items) when is_list(items) do
        Enum.map(items, &set_docs/1)
      end

      defp set_docs(%Ash.Dsl.Entity{} = entity) do
        entity
        |> Map.put(:docs, Ash.Dsl.Extension.doc_entity(entity))
        |> Map.put(
          :entities,
          Enum.map(entity.entities || [], fn {key, value} -> {key, set_docs(value)} end)
        )
      end

      defp set_docs(%Ash.Dsl.Section{} = section) do
        section
        |> Map.put(:entities, set_docs(section.entities))
        |> Map.put(:sections, set_docs(section.sections))
        |> Map.put(:docs, Ash.Dsl.Extension.doc_section(section))
      end

      @doc false
      def transformers, do: @_transformers
    end
  end

  @doc false
  def prepare(extensions) do
    body =
      quote location: :keep do
        @extensions unquote(extensions)
      end

    imports =
      for extension <- extensions || [] do
        extension = Macro.expand_once(extension, __ENV__)

        quote location: :keep do
          require Ash.Dsl.Extension
          import unquote(extension), only: :macros
        end
      end

    [body | imports]
  end

  @doc false
  defmacro set_state(additional_persisted_data) do
    quote generated: true,
          location: :keep,
          bind_quoted: [additional_persisted_data: additional_persisted_data] do
      alias Ash.Dsl.Transformer

      persist =
        additional_persisted_data
        |> Keyword.put(:extensions, @extensions || [])
        |> Enum.into(%{})

      ash_dsl_config =
        {__MODULE__, :ash_sections}
        |> Process.get([])
        |> Enum.map(fn {_extension, section_path} ->
          {section_path,
           Process.get(
             {__MODULE__, :ash, section_path},
             []
           )}
        end)
        |> Enum.into(%{})
        |> Map.update(
          :persist,
          persist,
          &Map.merge(&1, persist)
        )

      @ash_dsl_config ash_dsl_config

      for {key, _value} <- Process.get() do
        if is_tuple(key) and elem(key, 0) == __MODULE__ do
          Process.delete(key)
        end
      end

      transformers_to_run =
        @extensions
        |> Enum.flat_map(& &1.transformers())
        |> Transformer.sort()
        |> Enum.reject(& &1.after_compile?())

      __MODULE__
      |> Ash.Dsl.Extension.run_transformers(
        transformers_to_run,
        ash_dsl_config,
        true,
        __ENV__
      )
    end
  end

  def run_transformers(mod, transformers, ash_dsl_config, store?, env) do
    Enum.reduce_while(transformers, ash_dsl_config, fn transformer, dsl ->
      result =
        try do
          transformer.transform(mod, dsl)
        rescue
          e ->
            if Exception.exception?(e) do
              reraise e, __STACKTRACE__
            else
              reraise "Exception in transformer #{inspect(transformer)}: \n\n#{Exception.message(e)}",
                      __STACKTRACE__
            end
        end

      case result do
        :ok ->
          {:cont, dsl}

        :halt ->
          {:halt, dsl}

        {:warn, new_dsl, warnings} ->
          warnings
          |> List.wrap()
          |> Enum.each(&IO.warn(&1, Macro.Env.stacktrace(env)))

          if store? do
            Module.put_attribute(mod, :ash_dsl_config, new_dsl)
          end

          {:cont, new_dsl}

        {:ok, new_dsl} ->
          if store? do
            Module.put_attribute(mod, :ash_dsl_config, new_dsl)
          end

          {:cont, new_dsl}

        {:error, error} ->
          raise_transformer_error(transformer, error)
      end
    end)
  end

  defp raise_transformer_error(transformer, error) do
    if Exception.exception?(error) do
      raise error
    else
      raise "Error while running transformer #{inspect(transformer)}: #{inspect(error)}"
    end
  end

  @doc false
  def all_section_paths(path, prior \\ [])
  def all_section_paths(nil, _), do: []
  def all_section_paths([], _), do: []

  def all_section_paths(sections, prior) do
    Enum.flat_map(sections, fn section ->
      nested = all_section_paths(section.sections, [section.name, prior])

      [Enum.reverse(prior) ++ [section.name] | nested]
    end)
    |> Enum.uniq()
  end

  @doc false
  def all_section_config_paths(path, prior \\ [])
  def all_section_config_paths(nil, _), do: []
  def all_section_config_paths([], _), do: []

  def all_section_config_paths(sections, prior) do
    Enum.flat_map(sections, fn section ->
      nested = all_section_config_paths(section.sections, [section.name, prior])

      fields =
        Enum.map(section.schema, fn {key, _} ->
          {Enum.reverse(prior) ++ [section.name], key}
        end)

      fields ++ nested
    end)
    |> Enum.uniq()
  end

  @doc false
  defmacro build(extension, module_prefix, sections) do
    quote bind_quoted: [sections: sections, extension: extension, module_prefix: module_prefix] do
      alias Ash.Dsl.Extension

      for section <- sections do
        Extension.build_section(extension, section, [], [], module_prefix)
      end
    end
  end

  @doc false
  defmacro build_section(extension, section, unimports \\ [], path \\ [], module_prefix \\ nil) do
    quote bind_quoted: [
            section: section,
            path: path,
            extension: extension,
            module_prefix: module_prefix,
            unimports: Macro.escape(unimports)
          ] do
      alias Ash.Dsl

      {section_modules, entity_modules, opts_module} =
        Dsl.Extension.do_build_section(
          module_prefix || __MODULE__,
          extension,
          section,
          path,
          unimports
        )

      @doc false
      # This macro argument is only called `body` so that it looks nicer
      # in the DSL docs

      @doc false
      defmacro unquote(section.name)(body) do
        opts_module = unquote(opts_module)
        section_path = unquote(path ++ [section.name])
        section = unquote(Macro.escape(section))
        unimports = unquote(Macro.escape(unimports))

        configured_imports =
          for module <- unquote(section.imports) do
            quote do
              import unquote(module)
            end
          end

        entity_imports =
          for module <- unquote(entity_modules) do
            quote do
              import unquote(module), only: :macros
            end
          end

        section_imports =
          for module <- unquote(section_modules) do
            quote do
              import unquote(module), only: :macros
            end
          end

        opts_import =
          if Map.get(unquote(Macro.escape(section)), :schema, []) == [] do
            []
          else
            [
              quote do
                import unquote(opts_module)
              end
            ]
          end

        configured_unimports =
          for module <- unquote(section.imports) do
            quote do
              import unquote(module), only: []
            end
          end

        entity_unimports =
          for module <- unquote(entity_modules) do
            quote do
              import unquote(module), only: []
            end
          end

        section_unimports =
          for module <- unquote(section_modules) do
            quote do
              import unquote(module), only: []
            end
          end

        opts_unimport =
          if Map.get(unquote(Macro.escape(section)), :schema, []) == [] do
            []
          else
            [
              quote do
                import unquote(opts_module), only: []
              end
            ]
          end

        entity_imports ++
          section_imports ++
          opts_import ++
          configured_imports ++
          unimports ++
          [
            quote do
              unquote(body[:do])

              current_config =
                Process.get(
                  {__MODULE__, :ash, unquote(section_path)},
                  %{entities: [], opts: []}
                )

              opts =
                case Ash.OptionsHelpers.validate(
                       current_config.opts,
                       Map.get(unquote(Macro.escape(section)), :schema, [])
                     ) do
                  {:ok, opts} ->
                    opts

                  {:error, error} ->
                    raise Ash.Error.Dsl.DslError,
                      module: __MODULE__,
                      message: error,
                      path: unquote(section_path)
                end

              Process.put(
                {__MODULE__, :ash, unquote(section_path)},
                %{
                  entities: current_config.entities,
                  opts: opts
                }
              )
            end
          ] ++
          configured_unimports ++
          opts_unimport ++ entity_unimports ++ section_unimports
      end
    end
  end

  defp entity_mod_name(mod, nested_entity_path, section_path, entity) do
    nested_entity_parts = Enum.map(nested_entity_path, &Macro.camelize(to_string(&1)))
    section_path_parts = Enum.map(section_path, &Macro.camelize(to_string(&1)))

    mod_parts =
      Enum.concat([
        [mod],
        section_path_parts,
        nested_entity_parts,
        [Macro.camelize(to_string(entity.name))]
      ])

    Module.concat(mod_parts)
  end

  defp section_mod_name(mod, path, section) do
    nested_mod_name =
      path
      |> Enum.drop(1)
      |> Enum.map(fn nested_section_name ->
        Macro.camelize(to_string(nested_section_name))
      end)

    Module.concat([mod | nested_mod_name] ++ [Macro.camelize(to_string(section.name))])
  end

  defp unimports(mod, section, path, opts_mod_name) do
    entity_modules =
      Enum.map(section.entities, fn entity ->
        entity_mod_name(mod, [], path ++ [section.name], entity)
      end)

    entity_unimports =
      for module <- entity_modules do
        quote do
          import unquote(module), only: []
        end
      end

    section_modules =
      Enum.map(section.sections, fn section ->
        section_mod_name(mod, path, section)
      end)

    section_unimports =
      for module <- section_modules do
        quote do
          import unquote(module), only: []
        end
      end

    opts_unimport =
      if section.schema == [] do
        []
      else
        [
          quote do
            import unquote(opts_mod_name), only: []
          end
        ]
      end

    opts_unimport ++ section_unimports ++ entity_unimports
  end

  @doc false
  def do_build_section(mod, extension, section, path, unimports) do
    opts_mod_name =
      if section.schema == [] do
        nil
      else
        Module.concat([mod, Macro.camelize(to_string(section.name)), Options])
      end

    entity_modules =
      Enum.map(section.entities, fn entity ->
        entity = %{
          entity
          | auto_set_fields: Keyword.merge(section.auto_set_fields, entity.auto_set_fields)
        }

        build_entity(
          mod,
          extension,
          path ++ [section.name],
          entity,
          section.deprecations,
          [],
          unimports ++
            unimports(
              mod,
              %{section | entities: Enum.reject(section.entities, &(&1.name == entity.name))},
              path,
              opts_mod_name
            )
        )
      end)

    section_modules =
      Enum.map(section.sections, fn nested_section ->
        nested_mod_name =
          path
          |> Enum.drop(1)
          |> Enum.map(fn nested_section_name ->
            Macro.camelize(to_string(nested_section_name))
          end)

        mod_name =
          Module.concat(
            [mod | nested_mod_name] ++ [Macro.camelize(to_string(nested_section.name))]
          )

        {:module, module, _, _} =
          Module.create(
            mod_name,
            quote do
              alias Ash.Dsl

              require Dsl.Extension

              Dsl.Extension.build_section(
                unquote(extension),
                unquote(Macro.escape(nested_section)),
                unquote(unimports),
                unquote(path ++ [section.name]),
                nil
              )
            end,
            Macro.Env.location(__ENV__)
          )

        module
      end)

    if opts_mod_name do
      Module.create(
        opts_mod_name,
        quote bind_quoted: [
                section: Macro.escape(section),
                section_path: path ++ [section.name],
                extension: extension
              ] do
          for {field, _opts} <- section.schema do
            defmacro unquote(field)(value) do
              section_path = unquote(Macro.escape(section_path))
              field = unquote(Macro.escape(field))
              extension = unquote(extension)
              section = unquote(Macro.escape(section))

              Ash.Dsl.Extension.maybe_deprecated(
                field,
                section.deprecations,
                section_path,
                __CALLER__
              )

              value =
                cond do
                  field in section.modules ->
                    Ash.Dsl.Extension.expand_alias(value, __CALLER__)

                  field in section.no_depend_modules ->
                    Ash.Dsl.Extension.expand_alias_no_require(value, __CALLER__)

                  true ->
                    value
                end

              quote do
                current_sections = Process.get({__MODULE__, :ash_sections}, [])

                unless {unquote(extension), unquote(section_path)} in current_sections do
                  Process.put({__MODULE__, :ash_sections}, [
                    {unquote(extension), unquote(section_path)} | current_sections
                  ])
                end

                current_config =
                  Process.get(
                    {__MODULE__, :ash, unquote(section_path)},
                    %{entities: [], opts: []}
                  )

                Process.put(
                  {__MODULE__, :ash, unquote(section_path)},
                  %{
                    current_config
                    | opts: Keyword.put(current_config.opts, unquote(field), unquote(value))
                  }
                )
              end
            end
          end
        end,
        Macro.Env.location(__ENV__)
      )
    end

    {section_modules, entity_modules, opts_mod_name}
  end

  @doc false
  def build_entity(
        mod,
        extension,
        section_path,
        entity,
        deprecations,
        nested_entity_path,
        unimports,
        nested_key \\ nil
      ) do
    mod_name = entity_mod_name(mod, nested_entity_path, section_path, entity)

    options_mod_name = Module.concat(mod_name, "Options")

    nested_entity_mods =
      Enum.flat_map(entity.entities, fn {key, entities} ->
        entities
        |> List.wrap()
        |> Enum.map(fn nested_entity ->
          nested_entity_mod_names =
            entity.entities
            |> Enum.flat_map(fn {key, entities} ->
              entities
              |> List.wrap()
              |> Enum.reject(&(&1.name == nested_entity.name))
              |> Enum.map(fn nested_entity ->
                entity_mod_name(
                  mod_name,
                  nested_entity_path ++ [key],
                  section_path,
                  nested_entity
                )
              end)
            end)

          unimports =
            unimports ++
              Enum.map([options_mod_name | nested_entity_mod_names], fn mod_name ->
                quote do
                  import unquote(mod_name), only: []
                end
              end)

          build_entity(
            mod_name,
            extension,
            section_path,
            nested_entity,
            nested_entity.deprecations,
            nested_entity_path ++ [key],
            unimports,
            key
          )
        end)
      end)

    Ash.Dsl.Extension.build_entity_options(
      options_mod_name,
      entity,
      nested_entity_path
    )

    args = Enum.map(entity.args, &Macro.var(&1, mod_name))

    Module.create(
      mod_name,
      quote bind_quoted: [
              extension: extension,
              entity: Macro.escape(entity),
              args: Macro.escape(args),
              section_path: Macro.escape(section_path),
              options_mod_name: Macro.escape(options_mod_name),
              nested_entity_mods: Macro.escape(nested_entity_mods),
              nested_entity_path: Macro.escape(nested_entity_path),
              deprecations: deprecations,
              unimports: Macro.escape(unimports),
              nested_key: nested_key
            ] do
        defmacro unquote(entity.name)(unquote_splicing(args), opts \\ []) do
          section_path = unquote(Macro.escape(section_path))
          entity_schema = unquote(Macro.escape(entity.schema))
          entity = unquote(Macro.escape(entity))
          entity_name = unquote(Macro.escape(entity.name))
          entity_args = unquote(Macro.escape(entity.args))
          entity_deprecations = unquote(entity.deprecations)
          options_mod_name = unquote(Macro.escape(options_mod_name))
          source = unquote(__MODULE__)
          extension = unquote(Macro.escape(extension))
          nested_entity_mods = unquote(Macro.escape(nested_entity_mods))
          nested_entity_path = unquote(Macro.escape(nested_entity_path))
          deprecations = unquote(deprecations)
          unimports = unquote(Macro.escape(unimports))
          nested_key = unquote(nested_key)

          Ash.Dsl.Extension.maybe_deprecated(
            entity.name,
            deprecations,
            section_path ++ nested_entity_path,
            __CALLER__
          )

          arg_values =
            entity_args
            |> Enum.zip(unquote(args))
            |> Enum.map(fn {key, value} ->
              Ash.Dsl.Extension.maybe_deprecated(
                key,
                entity_deprecations,
                nested_entity_path,
                __CALLER__
              )

              cond do
                key in entity.modules ->
                  Ash.Dsl.Extension.expand_alias(value, __CALLER__)

                key in entity.no_depend_modules ->
                  Ash.Dsl.Extension.expand_alias_no_require(value, __CALLER__)

                true ->
                  value
              end
            end)

          opts =
            Enum.map(opts, fn {key, value} ->
              Ash.Dsl.Extension.maybe_deprecated(
                key,
                entity_deprecations,
                nested_entity_path,
                __CALLER__
              )

              cond do
                key in entity.modules ->
                  {key, Ash.Dsl.Extension.expand_alias(value, __CALLER__)}

                key in entity.no_depend_modules ->
                  {key, Ash.Dsl.Extension.expand_alias_no_require(value, __CALLER__)}

                true ->
                  {key, value}
              end
            end)

          code =
            unimports ++
              [
                quote do
                  section_path = unquote(section_path)
                  entity_name = unquote(entity_name)
                  extension = unquote(extension)
                  recursive_as = unquote(entity.recursive_as)
                  nested_key = unquote(nested_key)

                  original_nested_entity_path = Process.get(:recursive_builder_path)

                  nested_entity_path =
                    if is_nil(original_nested_entity_path) do
                      Process.put(:recursive_builder_path, [])
                      []
                    else
                      unless recursive_as || nested_key do
                        raise "Somehow got a nested entity without a `recursive_as` or `nested_key`"
                      end

                      path = (original_nested_entity_path || []) ++ [recursive_as || nested_key]

                      Process.put(
                        :recursive_builder_path,
                        path
                      )

                      path
                    end

                  current_sections = Process.get({__MODULE__, :ash_sections}, [])

                  keyword_opts =
                    Keyword.merge(
                      unquote(Keyword.delete(opts, :do)),
                      Enum.zip(unquote(entity_args), unquote(arg_values))
                    )

                  Process.put(
                    {:builder_opts, nested_entity_path},
                    keyword_opts
                  )

                  import unquote(options_mod_name)

                  require Ash.Dsl.Extension
                  Ash.Dsl.Extension.import_mods(unquote(nested_entity_mods))

                  unquote(opts[:do])

                  Process.put(:recursive_builder_path, original_nested_entity_path)

                  current_config =
                    Process.get(
                      {__MODULE__, :ash, section_path ++ nested_entity_path},
                      %{entities: [], opts: []}
                    )

                  import unquote(options_mod_name), only: []

                  require Ash.Dsl.Extension
                  Ash.Dsl.Extension.unimport_mods(unquote(nested_entity_mods))

                  opts = Process.delete({:builder_opts, nested_entity_path})

                  alias Ash.Dsl.Entity

                  nested_entity_keys =
                    unquote(Macro.escape(entity.entities))
                    |> Enum.map(&elem(&1, 0))
                    |> Enum.uniq()

                  nested_entities =
                    nested_entity_keys
                    |> Enum.reduce(%{}, fn key, acc ->
                      nested_path = section_path ++ nested_entity_path ++ [key]

                      entities =
                        {__MODULE__, :ash, nested_path}
                        |> Process.get(%{entities: []})
                        |> Map.get(:entities, [])

                      Process.delete({__MODULE__, :ash, nested_path})

                      Map.update(acc, key, entities, fn current_nested_entities ->
                        (current_nested_entities || []) ++ entities
                      end)
                    end)

                  built =
                    case Entity.build(unquote(Macro.escape(entity)), opts, nested_entities) do
                      {:ok, built} ->
                        built

                      {:error, error} ->
                        additional_path =
                          if opts[:name] do
                            [unquote(entity.name), opts[:name]]
                          else
                            [unquote(entity.name)]
                          end

                        message =
                          cond do
                            Exception.exception?(error) ->
                              Exception.message(error)

                            is_binary(error) ->
                              error

                            true ->
                              inspect(error)
                          end

                        raise Ash.Error.Dsl.DslError,
                          module: __MODULE__,
                          message: message,
                          path: section_path ++ additional_path
                    end

                  new_config = %{current_config | entities: current_config.entities ++ [built]}

                  unless {extension, section_path} in current_sections do
                    Process.put({__MODULE__, :ash_sections}, [
                      {extension, section_path} | current_sections
                    ])
                  end

                  Process.put(
                    {__MODULE__, :ash, section_path ++ nested_entity_path},
                    new_config
                  )
                end
              ]

          # This is (for some reason I'm not really sure why) necessary to keep the imports within a lexical scope
          quote do
            try do
              unquote(code)
            rescue
              e ->
                reraise e, __STACKTRACE__
            end
          end
        end
      end,
      Macro.Env.location(__ENV__)
    )

    mod_name
  end

  defmacro import_mods(mods) do
    for mod <- mods do
      quote do
        import unquote(mod)
      end
    end
  end

  defmacro unimport_mods(mods) do
    for mod <- mods do
      quote do
        import unquote(mod), only: []
      end
    end
  end

  @doc false
  def build_entity_options(
        module_name,
        entity,
        nested_entity_path
      ) do
    Module.create(
      module_name,
      quote bind_quoted: [
              entity: Macro.escape(entity),
              nested_entity_path: nested_entity_path
            ] do
        for {key, _value} <- entity.schema do
          defmacro unquote(key)(value) do
            key = unquote(key)
            modules = unquote(entity.modules)
            no_depend_modules = unquote(entity.no_depend_modules)
            deprecations = unquote(entity.deprecations)
            entity_name = unquote(entity.name)
            recursive_as = unquote(entity.recursive_as)

            nested_entity_path = Process.get(:recursive_builder_path)

            Ash.Dsl.Extension.maybe_deprecated(key, deprecations, nested_entity_path, __CALLER__)

            value =
              cond do
                key in modules ->
                  Ash.Dsl.Extension.expand_alias(value, __CALLER__)

                key in no_depend_modules ->
                  Ash.Dsl.Extension.expand_alias_no_require(value, __CALLER__)

                true ->
                  value
              end

            quote do
              current_opts = Process.get({:builder_opts, nested_entity_path}, [])

              Process.put(
                {:builder_opts, nested_entity_path},
                Keyword.put(current_opts, unquote(key), unquote(value))
              )
            end
          end
        end
      end,
      file: __ENV__.file
    )

    module_name
  end

  @doc false
  def maybe_deprecated(field, deprecations, path, env) do
    if Keyword.has_key?(deprecations, field) do
      prefix =
        case Enum.join(path) do
          "" -> ""
          path -> "#{path}."
        end

      IO.warn(
        "The #{prefix}#{field} key will be deprecated in an upcoming release!\n\n#{deprecations[field]}",
        Macro.Env.stacktrace(env)
      )
    end
  end

  def expand_alias(ast, %Macro.Env{} = env) do
    Macro.postwalk(ast, fn
      {:__aliases__, _, _} = node ->
        Macro.expand(node, %{env | function: {:__ash_placeholder__, 0}})

      other ->
        other
    end)
  end

  def expand_alias_no_require(ast, %Macro.Env{} = env) do
    Macro.postwalk(ast, fn
      {:__aliases__, _, _} = node ->
        Macro.expand(node, %{env | lexical_tracker: nil})

      other ->
        other
    end)
  end
end