Packages
igniter
0.8.0
0.8.2
0.8.1
0.8.0
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.30
0.6.29
0.6.28
0.6.27
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.52
0.5.51
0.5.50
0.5.49
0.5.48
0.5.47
0.5.46
0.5.45
0.5.44
0.5.43
0.5.42
0.5.41
0.5.40
0.5.39
0.5.38
0.5.37
0.5.36
0.5.35
0.5.34
0.5.33
0.5.32
0.5.31
0.5.30
0.5.29
0.5.28
0.5.27
0.5.26
0.5.25
0.5.24
0.5.23
0.5.22
0.5.21
0.5.20
0.5.19
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.78
0.3.77
0.3.76
0.3.75
0.3.74
0.3.73
0.3.72
0.3.71
0.3.70
0.3.69
0.3.68
0.3.67
0.3.66
0.3.65
0.3.64
0.3.63
0.3.62
0.3.61
0.3.60
0.3.59
0.3.58
0.3.57
0.3.56
0.3.55
0.3.54
0.3.53
0.3.52
0.3.51
0.3.50
0.3.49
0.3.48
0.3.47
0.3.46
0.3.45
0.3.44
0.3.43
0.3.42
0.3.41
0.3.40
0.3.39
0.3.38
0.3.37
0.3.36
0.3.35
0.3.34
0.3.33
0.3.32
0.3.31
0.3.30
0.3.29
0.3.28
0.3.27
0.3.26
0.3.25
0.3.24
0.3.23
0.3.22
0.3.21
0.3.20
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.8
0.1.7
0.1.6
0.1.5
0.1.4
0.1.3
0.1.2
0.1.1
0.1.0
A code generation and project patching framework
Current section
Files
Jump to
Current section
Files
lib/igniter/project/mix_project.ex
# SPDX-FileCopyrightText: 2024 igniter contributors <https://github.com/ash-project/igniter/graphs/contributors>
#
# SPDX-License-Identifier: MIT
defmodule Igniter.Project.MixProject do
@moduledoc """
Codemods and utilities for updating project configuration in mix.exs.
"""
require Igniter.Code.Common
alias Igniter.Code.Common
alias Sourceror.Zipper
@doc """
Updates the project configuration AST at the given path.
This function accepts a `function_name` atom corresponding to a function
like `project/0`, `application/0`, or `cli/0` and navigates to the given
path, jumping to private functions if necessary and creating nested
keyword lists if they don't already exist. It then calls the given
`update_fun`, using the return value to update the AST.
`update_fun` must be a function that accepts one argument, a zipper
targeting the current AST at the given configuration path or `nil` if
there was no value at that path. It then must return one of the
following:
* `{:ok, zipper}` - the updated zipper
* `{:ok, {:code, quoted}}` - a quoted expression that should be
inserted as the new value at `path`
* `{:ok, nil}` - indicates that the last key in `path` should be
removed
* `{:error, message}` or `{:warning, message}` - an error or warning
that should be added to `igniter`
* `:error` - indicates `igniter` should be returned without change
## Examples
Assuming a newly-generated Mix project that looks like:
defmodule Example.MixProject do
use Mix.Project
def project do
[
app: :example,
version: "0.1.0",
elixir: "~> 1.17",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[]
end
end
### Increment the project version by one patch level
Igniter.Project.MixProject.update(igniter, :project, [:version], fn zipper ->
new_version =
zipper.node
|> Version.parse!()
|> Map.update!(:patch, &(&1 + 1))
|> to_string()
{:ok, {:code, new_version}}
end)
# would result in
def project do
[
...,
version: "0.1.1",
...
]
end
### Set the preferred env for a task to `:test`
Igniter.Project.MixProject.update(
igniter,
:cli,
[:preferred_envs, :"some.task"],
fn _ -> {:ok, {:code, :test}} end
)
# would create `cli/0` and set the env:
def cli do
[
preferred_envs: [
"some.task": :test
]
]
end
### Add `:some_application` to `:extra_applications`
Igniter.Project.MixProject.update(igniter, :application, [:extra_applications], fn
nil -> {:ok, {:code, [:some_application]}}
zipper -> Igniter.Code.List.append_to_list(zipper, :some_application)
end)
# would result in
def application do
[
extra_applications: [:logger, :some_application]
]
end
### Remove `:extra_applications` altogether
Igniter.Project.MixProject.update(
igniter,
:application,
[:extra_applications],
fn _ -> {:ok, nil} end
)
# would result in
def application do
[]
end
"""
@spec update(
Igniter.t(),
function_name :: atom(),
path :: [atom(), ...],
update_fun ::
(Zipper.t() | nil ->
{:ok, Zipper.t() | nil | {:code, quoted :: Macro.t()}}
| {:error | :warning, term()}
| :error)
) :: Igniter.t()
def update(%Igniter{} = igniter, function_name, path, update_fun)
when is_atom(function_name) and is_function(update_fun, 1) do
path = ensure_path!(path)
Igniter.update_elixir_file(igniter, "mix.exs", fn zipper ->
with {:ok, zipper} <- move_to_or_create_def_0(zipper, function_name),
zipper <- resolve_to_config(zipper),
{:ok, zipper} <- build_and_resolve_path(zipper, path) do
update_resolved(zipper, update_fun)
else
:error ->
{:error, "Unable to update mix.exs config for #{function_name}/0: #{inspect(path)}"}
end
end)
end
defp move_to_or_create_def_0(zipper, function_name) do
case Igniter.Code.Function.move_to_def(zipper, function_name, 0) do
{:ok, zipper} ->
{:ok, zipper}
:error ->
zipper
|> get_last_def()
|> Common.add_code("""
def #{function_name} do
[
]
end
""")
|> Igniter.Code.Function.move_to_def(function_name, 0)
end
end
defp get_last_def(zipper, last \\ nil) do
with {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper),
{:ok, next} <- Common.move_right(zipper, 1) do
get_last_def(next, zipper)
else
_ -> last
end
end
defp build_and_resolve_path(zipper, []), do: {:ok, zipper}
defp build_and_resolve_path(zipper, [key | rest]) do
with {:ok, zipper} <- ensure_key(zipper, key) do
zipper
|> resolve_to_config()
|> build_and_resolve_path(rest)
end
end
defp resolve_to_config(zipper) do
zipper
|> Common.maybe_move_to_single_child_block()
|> maybe_resolve_local_function_call()
|> maybe_resolve_module_attribute()
|> maybe_resolve_block_to_last_child()
end
defp maybe_resolve_local_function_call(zipper) do
with {:ok, {name, arity}} <- Igniter.Code.Function.get_local_function_call(zipper),
{:ok, zipper} <-
Common.move_upwards(zipper, &Common.node_matches_pattern?(&1, {:defmodule, _, _})),
{:ok, zipper} <- move_to_function_def(zipper, name, arity) do
resolve_to_config(zipper)
else
_ -> zipper
end
end
defp maybe_resolve_module_attribute(%Zipper{node: {:@, _, [{attr, _, nil}]}} = zipper) do
case Common.find_prev(zipper, &Common.node_matches_pattern?(&1, {:@, _, [{^attr, _, [_]}]})) do
{:ok, zipper} -> zipper |> Zipper.down() |> Zipper.down()
:error -> zipper
end
end
defp maybe_resolve_module_attribute(zipper), do: zipper
defp maybe_resolve_block_to_last_child(%Zipper{node: {:__block__, _, _}} = zipper) do
zipper
|> Zipper.down()
|> Zipper.rightmost()
|> resolve_to_config()
end
defp maybe_resolve_block_to_last_child(zipper), do: zipper
defp move_to_function_def(zipper, name, arity) do
with :error <- Igniter.Code.Function.move_to_def(zipper, name, arity) do
Igniter.Code.Function.move_to_defp(zipper, name, arity)
end
end
defp ensure_key(zipper, key) do
zipper = maybe_replace_nil_with_list(zipper)
if Igniter.Code.List.list?(zipper) do
case Igniter.Code.Keyword.set_keyword_key(zipper, key, nil) do
{:ok, zipper} -> Igniter.Code.Keyword.get_key(zipper, key)
:error -> :error
end
else
:error
end
end
defp maybe_replace_nil_with_list(%Zipper{node: nil} = zipper), do: Zipper.replace(zipper, [])
defp maybe_replace_nil_with_list(zipper), do: zipper
defp update_resolved(zipper, update_fun) do
zipper_or_nil = if(is_nil(zipper.node), do: nil, else: zipper)
case update_fun.(zipper_or_nil) do
{:ok, nil} ->
{:ok, zipper} = Common.move_upwards(zipper, &Common.node_matches_pattern?(&1, {_, _}))
{:ok, Zipper.remove(zipper)}
{:ok, {:code, quoted}} when is_binary(quoted) ->
quoted = Sourceror.parse_string!(quoted)
{:ok, Common.replace_code(zipper, quoted)}
{:ok, {:code, quoted}} ->
quoted =
quoted
|> Sourceror.to_string()
|> Sourceror.parse_string!()
{:ok, Common.replace_code(zipper, quoted)}
other ->
other
end
end
defp ensure_path!(path) do
non_empty? = Enum.empty?(path)
all_atoms? = Enum.all?(path, &is_atom(&1))
if non_empty? or not all_atoms? do
raise ArgumentError, "path must be a non-empty list of atoms, got: #{inspect(path)}"
end
Enum.to_list(path)
end
end