lib/uniform/app.ex

defmodule Uniform.App do
  @moduledoc """
  A struct representing an app to be ejected. See the [type
  definition](`t:t/0`) for more details.

  ## Where it's availabile

  An `app` struct is available in the Blueprint in these callbacks and macros:

  - [`base_files`](`Uniform.Blueprint.base_files/1`)
  - [`modify`](`Uniform.Blueprint.modify/2`)
  - [`extra`](`c:Uniform.Blueprint.extra/1`)
  - [`target_path`](`c:Uniform.Blueprint.target_path/2`)

  Use the `app` to make decisions about what to eject or how files should be
  modified.

  ## Checking for dependencies

  In the callbacks and macros above, you can use `depends_on?` to determine
  whether an app depends on a mix or lib dependency.

      depends_on?(app, :mix, :norm)

  See `depends_on?/3` for more information.

  """

  alias __MODULE__
  alias Uniform.{Manifest, Config, LibDep, MixDep}

  @derive {Inspect, except: [:internal, :destination]}
  defstruct [:internal, :name, :destination, :extra]

  defmodule Deps do
    @moduledoc """
                          A struct containing all dependencies associated with an ejectable app.

                          Intended to be attached to the `deps` field of
                          `t:Uniform.App.t/0`.

                            - `:lib` – all included `%LibDeps{}`
                            - `:mix` – all included `%MixDeps{}`
                            - `:included` – all included lib and mix deps as atom names
                              (same as pulling keys from above structs)
                            - `:all` – *all* mix and lib dep names that _could_ be
                              included in an app. The `all` field helps identify and warn on
                              references to mix or lib deps that are not in `mix.exs` or
                              `lib/`.
               """ && false

    defstruct [:lib, :mix, :included, :all]

    alias Uniform.{LibDep, MixDep, Manifest, Config}

    @type t :: %__MODULE__{
            lib: %{LibDep.name() => LibDep.t()},
            mix: %{MixDep.name() => MixDep.t()},
            included: %{
              lib: [LibDep.name()],
              mix: [MixDep.name()]
            },
            all: %{
              lib: [LibDep.name()],
              mix: [MixDep.name()]
            }
          }
  end

  @typedoc """
  An App struct, representing a discrete, self-contained app to be ejected.

  ## Example

  Note that the `extra` key contains everything you put in `extra` in
  `uniform.exs` for the given app. It also contains anything returned by
  `c:Uniform.Blueprint.extra/1`. (`uniform.exs` has precedence for conflicting
  keys.)

      #Uniform.App<
        extra: [
          company: :fake_co,
          logo_file: "path/to/some_logo.png",
          some_data: "from uniform.exs"
        ],
        name: %{
          camel: "Tweeter",
          hyphen: "tweeter",
          module: Tweeter,
          underscore: "tweeter"
        },
        ...
      >

  """
  @type t :: %__MODULE__{
          name: %{
            module: module,
            hyphen: String.t(),
            underscore: String.t(),
            camel: String.t()
          },
          destination: Path.t(),
          extra: keyword
        }

  @typep new_opt :: {:destination, String.t()}

  @doc """
       Initializes a new `%App{}` struct.

       ### Example

           new!(config, manifest, "tweeter")

           %Uniform.App{
             config: %Config{...},
             name: %{
               module: Tweeter,
               hyphen: "tweeter",
               underscore: "tweeter",
               camel: "Tweeter"
             },
             destination: "...",
             deps: %Deps{
               lib: %{
                 included_lib: %LibDep{...},
                 indirectly_included_lib: %LibDep{...}
               },
               mix: %{
                 included_mix: %MixDep{...},
                 indirectly_included_mix: %MixDep{...}
               },
               included: %{
                 lib: [:included_lib, :indirectly_included_lib],
                 mix: [:included_mix, :indirectly_included_mix]
               },
               all: %{
                 lib: [:excluded_lib, :included_lib, :indirectly_included_lib],
                 mix: [:excluded_mix, :included_mix, :indirectly_included_mix]
               }
             },
             extra: [...]
           }

       """ && false
  @spec new!(Config.t(), Manifest.t(), String.t()) :: t
  @spec new!(Config.t(), Manifest.t(), String.t(), [new_opt]) :: t
  def new!(%Config{} = config, %Manifest{} = manifest, app_name_underscore_case, opts \\ [])
      when is_binary(app_name_underscore_case) do
    app_name_camel_case = Macro.camelize(app_name_underscore_case)

    app = %App{
      internal: %{
        config: config,
        deps: deps(config, manifest)
      },
      name: %{
        module: Module.concat("Elixir", app_name_camel_case),
        camel: app_name_camel_case,
        underscore: app_name_underscore_case,
        hyphen: String.replace(app_name_underscore_case, "_", "-")
      },
      destination: destination(app_name_underscore_case, config, opts)
    }

    {:module, _} = Code.ensure_loaded(config.blueprint)

    # `extra/1` requires an app struct
    extra =
      if function_exported?(config.blueprint, :extra, 1) do
        Keyword.merge(config.blueprint.extra(app), manifest.extra)
      else
        manifest.extra
      end

    %{app | extra: extra}
  end

  @doc """
  Indicates if an app requires a given dependency.

  Pass in the `app`, the dependency type (either `:lib` or `:mix`), and the
  name of the dependency (like `:tesla` or `:my_lib_directory`) and the
  function will return `true` if the dependency will be ejected along with the
  app.

  ## Examples

      depends_on?(app, :mix, :some_included_mix_dep)
      depends_on?(app, :mix, :not_included_dep)
      depends_on?(app, :lib, :some_included_lib)

  ## Examples in Context

      base_files do
        if depends_on?(app, :mix, :some_hex_dependency) do
          file "file_needed_by_some_hex_dependency"
        end
      end

      modify ~r/^test\/.+_(test).exs/, fn file, app ->
        if depends_on?(app, :lib, :my_data_lib) do
          file
        else
          String.replace(
            file,
            "use Oban.Testing, repo: MyDataLib.Repo",
            "use Oban.Testing, repo: OtherDataLib.Repo"
          )
        end
      end

  """
  @spec depends_on?(app :: t, category :: :lib | :mix, dep_name :: atom) :: boolean
  def depends_on?(app, category, dep_name) when category in [:lib, :mix] and is_atom(dep_name) do
    dep_name in app.internal.deps.included[category]
  end

  defp destination(app_name_underscore_case, config, opts) do
    destination =
      case {config.destination, opts[:destination]} do
        {nil, nil} -> "../" <> app_name_underscore_case
        {_, opt} when not is_nil(opt) -> opt
        {config, nil} -> Path.join(config, app_name_underscore_case)
      end

    Path.expand(destination)
  end

  # Given a manifest struct, returns a `%Deps{}` struct containing
  # information about lib and mix dependencies.
  @spec deps(Config.t(), Manifest.t()) :: t
  defp deps(config, manifest) do
    all_libs = Config.lib_deps(config)
    all_mixs = Config.mix_deps(config)
    included_libs = included_libs(manifest, all_libs)
    included_mixs = included_mixs(manifest, included_libs, all_mixs)

    %Deps{
      lib: included_libs,
      mix: included_mixs,
      included: %{
        lib: Map.keys(included_libs),
        mix: Map.keys(included_mixs)
      },
      all: %{
        lib: Map.keys(all_libs),
        mix: Map.keys(all_mixs)
      }
    }
  end

  @spec included_libs(Manifest.t(), %{atom => LibDep.t()}) :: %{atom => LibDep.t()}
  defp included_libs(manifest, all) do
    root_deps =
      all
      |> Enum.filter(fn {_, lib_dep} -> lib_dep.always || lib_dep.name in manifest.lib_deps end)
      |> Enum.into(%{})

    root_deps
    |> Map.values()
    |> Enum.reduce(root_deps, &gather_child_deps(&1, :lib_deps, &2, all))
  end

  @spec included_mixs(Manifest.t(), %{atom => LibDep.t()}, %{atom => MixDep.t()}) :: %{
          atom => MixDep.t()
        }
  defp included_mixs(manifest, included_libs, all_mixs) do
    root_deps =
      all_mixs
      |> Enum.filter(fn {_, mix_dep} -> mix_dep.always || mix_dep.name in manifest.mix_deps end)
      |> Enum.into(%{})

    # gather nested mix deps required by manifest
    root_deps =
      root_deps
      |> Enum.map(fn {_name, dep} -> dep end)
      |> Enum.reduce(
        root_deps,
        &gather_child_deps(&1, :mix_deps, &2, all_mixs)
      )

    # gather mix deps required by lib deps, which have already been flattened
    included_libs
    |> Map.values()
    |> Enum.reduce(root_deps, &gather_child_deps(&1, :mix_deps, &2, all_mixs))
  end

  @typep dep :: LibDep.t() | MixDep.t()

  @spec gather_child_deps(dep, :lib_deps | :mix_deps, %{atom => dep}, %{atom => dep}) :: %{
          atom => dep
        }
  defp gather_child_deps(dep, children_field, gathered, all_of_type) do
    dep
    |> Map.get(children_field, [])
    |> Enum.reduce(gathered, fn child_name, gathered ->
      if Map.has_key?(gathered, child_name) do
        # already gathered this one
        gathered
      else
        if Map.has_key?(all_of_type, child_name) do
          nested_dep = all_of_type[child_name]
          gathered = Map.put(gathered, child_name, nested_dep)
          # recurse to ensure we capture infinite potential levels of nesting
          gather_child_deps(nested_dep, children_field, gathered, all_of_type)
        else
          type =
            case dep do
              %LibDep{} -> :lib
              %MixDep{} -> :mix
            end

          raise "Could not find #{type} dependency #{child_name} which is a dependency of #{dep.name}"
        end
      end
    end)
  end
end