defmodule Igniter.Project.Application do @moduledoc "Codemods and tools for working with Application modules." require Igniter.Code.Common require Igniter.Code.Function alias Igniter.Code.Common alias Sourceror.Zipper @doc "Returns the name of the current application." @spec app_name() :: atom() @deprecated "Use `app_name/1` instead." def app_name do Mix.Project.config()[:app] end @doc "Returns the name of the application." @spec app_name(Igniter.t()) :: atom() def app_name(igniter) do zipper = igniter |> Igniter.include_existing_file("mix.exs") |> Map.get(:rewrite) |> Rewrite.source!("mix.exs") |> Rewrite.Source.get(:quoted) |> Sourceror.Zipper.zip() with {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper, :project, 0), zipper <- Igniter.Code.Common.rightmost(zipper), true <- Igniter.Code.List.list?(zipper), {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :app), {:ok, app_name} when is_atom(app_name) <- expand_key_value(zipper) do app_name else _ -> raise """ Failed to parse the application name from mix.exs. Please ensure that the `project` function in your mix.exs file returns a keyword list with an `app` key. """ end end @doc "Returns the config_path of the application." @spec config_path(Igniter.t()) :: binary() def config_path(igniter) do zipper = igniter |> Igniter.include_existing_file("mix.exs") |> Map.get(:rewrite) |> Rewrite.source!("mix.exs") |> Rewrite.Source.get(:quoted) |> Sourceror.Zipper.zip() with {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper, :project, 0), zipper <- Igniter.Code.Common.rightmost(zipper), true <- Igniter.Code.List.list?(zipper), {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :config_path), {:ok, config_path} when is_binary(config_path) <- expand_key_value(zipper) do config_path else _ -> # https://github.com/elixir-lang/elixir/blob/0bc8a60c9befd057be83d378d76c35850ef0d111/lib/mix/lib/mix/project.ex#L992 "config/config.exs" end end defp expand_key_value(zipper) do case Common.expand_literal(zipper) do :error -> expand_attribute(zipper) other -> other end end defp expand_attribute(zipper) do with {:@, _, [{attr, _, nil}]} <- zipper.node, {:ok, zipper} <- Common.find_prev(zipper, &match?({:@, _, [{^attr, _, [_]}]}, &1.node)) do zipper |> Zipper.down() |> Zipper.down() |> Common.expand_literal() else _ -> :error end end @doc "Returns the path of the application's priv directory." @spec priv_dir(Igniter.t(), [String.t()]) :: String.t() def priv_dir(igniter, subpath \\ []) do Path.join(["_build/#{Mix.env()}/lib/", to_string(app_name(igniter)), "priv"] ++ subpath) end @doc "Returns the name of the application module." def app_module(igniter) do zipper = igniter |> Igniter.include_existing_file("mix.exs") |> Map.get(:rewrite) |> Rewrite.source!("mix.exs") |> Rewrite.Source.get(:quoted) |> Sourceror.Zipper.zip() with {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper, :application, 0), zipper <- Sourceror.Zipper.down(zipper), zipper <- Igniter.Code.Common.rightmost(zipper), true <- Igniter.Code.List.list?(zipper), {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :mod) do case Igniter.Code.Common.expand_literal(zipper) do {:ok, {app_module, _}} -> app_module {:ok, app_module} -> app_module :error -> try do case Code.eval_quoted(zipper.node) do {{module, _}, _} -> module {module, _} when is_atom(module) -> module end rescue _ -> reraise """ An application module was configured, but we could not determine its name, cannot continue. """, __STACKTRACE__ end end else _ -> nil end end @doc """ Adds a new child to the `children` list in the application file To pass quoted code as the options, use the following format: {module, {:code, quoted_code}} i.e {MyApp.Supervisor, {:code, quote do Application.fetch_env!(:app, :config) end}} ## Options - `:after` - A list of other modules that this supervisor should appear after, or a function that takes a module and returns `true` if this module should be placed after it. - `:opts_updater` - A function that takes the current options (second element of the child tuple), and returns a new value. If the existing value of the module is not a tuple, the value passed into your function will be `[]`. Your function *must* return `{:ok, zipper}` or `{:error | :warning, "error_or_warning"}`. - `:force?` - If `true`, forces adding a new child, even if an existing child uses the same child module. Defaults to `false`. ## Ordering We will put the new child as the earliest item in the list that we can, skipping any modules in the `after` option. ## Examples Given an application `start/2` that looks like this: def start(_type, _args) do children = [ ChildOne, {ChildTwo, opt: 1} ] Supervisor.start_link(children, strategy: :one_for_one) end Add a new child that isn't currently present: Igniter.Project.Application.add_new_child(igniter, NewChild) # => children = [ NewChild, ChildOne, {ChildTwo, opt: 1} ] Add a new child after some existing ones: Igniter.Project.Application.add_new_child(igniter, NewChild, after: [ChildOne, ChildTwo]) # => children = [ ChildOne, {ChildTwo, opt: 1}, NewChild ] If the given child module is already present, `add_new_child/3` is a no-op by default: Igniter.Project.Application.add_new_child(igniter, {ChildOne, opt: 1}) # => children = [ ChildOne, {ChildTwo, opt: 1} ] You can explicitly handle module conflicts by passing an `:opts_updater`: Igniter.Project.Application.add_new_child(igniter, {ChildOne, opt: 1}, opts_updater: fn opts -> {:ok, Sourceror.Zipper.replace(opts, [opt: 1])} end ) # => children = [ {ChildOne, opt: 1}, {ChildTwo, opt: 1} ] Using `force?: true`, you can force a child to be added, even if the module conflicts with an existing one: Igniter.Project.Application.add_new_child(igniter, {ChildOne, opt: 1}, force?: true) # => children = [ {ChildOne, opt: 1}, ChildOne, {ChildTwo, opt: 1} ] """ @spec add_new_child( Igniter.t(), module() | {module, {:code, term()}} | {module, term()}, opts :: Keyword.t() ) :: Igniter.t() def add_new_child(igniter, to_supervise, opts \\ []) do opts = opts |> Keyword.update(:after, fn _ -> false end, fn fun when is_function(fun, 1) -> fun list -> fn item -> item in List.wrap(list) end end) |> Keyword.put_new(:force?, false) to_perform = case app_module(igniter) do nil -> {:create_an_app, Igniter.Project.Module.module_name(igniter, "Application")} {mod, _} -> {:modify, mod} mod -> {:modify, mod} end case to_perform do {:create_an_app, mod} -> igniter |> create_app(mod) |> do_add_child(mod, to_supervise, opts) {:modify, mod} -> do_add_child(igniter, mod, to_supervise, opts) end end def create_app(igniter, application) do igniter |> point_to_application_in_mix_exs(application) |> create_application_file(application) end defp do_add_child(igniter, application, to_supervise, opts) do to_supervise = case to_supervise do module when is_atom(module) -> module {module, {:code, contents}} when is_atom(module) -> {module, contents} {module, contents} -> {module, Macro.escape(contents)} end to_supervise_module = case to_supervise do {module, _} -> module module -> module end Igniter.Project.Module.find_and_update_module!(igniter, application, fn zipper -> with {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper, :start, 2), {:ok, zipper} <- Igniter.Code.Function.move_to_function_call_in_current_scope( zipper, :=, [2], fn call -> Igniter.Code.Function.argument_matches_pattern?( call, 0, {:children, _, context} when is_atom(context) ) && Igniter.Code.Function.argument_matches_pattern?(call, 1, v when is_list(v)) end ), {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1) do if opts[:force?] do insert_child(zipper, to_supervise, opts) else case Igniter.Code.List.move_to_list_item(zipper, fn item -> case extract_child_module(item) do {:ok, module} -> Igniter.Code.Common.nodes_equal?(module, to_supervise_module) :error -> false end end) do {:ok, zipper} -> if updater = opts[:opts_updater] do zipper = if Igniter.Code.Tuple.tuple?(zipper) do zipper else Zipper.replace(zipper, {zipper.node, []}) end {:ok, zipper} = Igniter.Code.Tuple.tuple_elem(zipper, 1) updater.(zipper) else {:ok, zipper} end :error -> insert_child(zipper, to_supervise, opts) end end else _ -> {:warning, """ Could not find a `children = [...]` assignment in the `start` function of the `#{application}` module. Please ensure that #{inspect(to_supervise)} is added started by the application `#{application}` manually. """} end end) end defp insert_child(zipper, child, opts) do zipper |> Zipper.down() |> then(fn zipper -> case Zipper.down(zipper) do nil -> {:ok, Zipper.insert_child(zipper, child)} zipper -> zipper |> skip_after(opts) |> case do {:after, zipper} -> {:ok, Zipper.insert_right(zipper, child)} {:before, zipper} -> {:ok, Zipper.insert_left(zipper, child)} end end end) end defp extract_child_module(zipper) do if Igniter.Code.Tuple.tuple?(zipper) do with {:ok, elem} <- Igniter.Code.Tuple.tuple_elem(zipper, 0) do {:ok, Igniter.Code.Common.expand_alias(elem)} end else {:ok, Igniter.Code.Common.expand_alias(zipper)} end end def skip_after(zipper, opts) do Igniter.Code.List.do_move_to_list_item(zipper, fn item -> with {:is_tuple, true} <- {:is_tuple, Igniter.Code.Tuple.tuple?(item)}, {:ok, item} <- Igniter.Code.Tuple.tuple_elem(item, 0), item <- Igniter.Code.Common.expand_alias(item), module when is_atom(module) <- alias_to_mod(item.node), true <- opts[:after].(module) do true else {:is_tuple, false} -> with item <- Igniter.Code.Common.expand_alias(item), module when is_atom(module) <- alias_to_mod(item.node), true <- opts[:after].(module) do true else _ -> false end _ -> false end end) |> case do {:ok, zipper} -> case Zipper.right(zipper) do nil -> {:after, zipper} zipper -> skip_after(zipper, Keyword.put(opts, :nested?, true)) end :error -> if Keyword.get(opts, :nested?) do {:after, zipper} else {:before, zipper} end end end def create_application_file(igniter, application) do path = Igniter.Project.Module.proper_location(igniter, application) supervisor = Igniter.Project.Module.module_name(igniter, "Supervisor") contents = """ defmodule #{inspect(application)} do @moduledoc false use Application @impl true def start(_type, _args) do children = [] opts = [strategy: :one_for_one, name: #{inspect(supervisor)}] Supervisor.start_link(children, opts) end end """ Igniter.create_new_file(igniter, path, contents) end defp point_to_application_in_mix_exs(igniter, application) do Igniter.update_elixir_file(igniter, "mix.exs", fn zipper -> case Igniter.Code.Module.move_to_module_using(zipper, Mix.Project) do {:ok, zipper} -> case Igniter.Code.Function.move_to_def(zipper, :application, 0) do {:ok, zipper} -> zipper |> Igniter.Code.Common.rightmost() |> Igniter.Code.Keyword.set_keyword_key(:mod, {application, []}, fn z -> code = {application, []} |> Sourceror.to_string() |> Sourceror.parse_string!() {:ok, Common.replace_code(z, code)} end) _ -> Common.add_code(zipper, """ def application do [ mod: {#{inspect(application)}, []} ] end """) end _ -> {:warning, """ No module in `mix.exs` using `Mix.Project` was found. Please update your `mix.exs` to point to the application module `#{inspect(application)}`. For example: def application do [ mod: {#{inspect(application)}, []} ] end """} end end) end defp alias_to_mod({:__aliases__, _, parts}), do: Module.concat(parts) defp alias_to_mod(v) when is_atom(v), do: v defp alias_to_mod(other), do: other end