Packages
igniter
0.5.17
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.ex
defmodule Igniter do
@moduledoc """
Tools for generating and patching code into an Elixir project.
"""
defstruct [
:rewrite,
issues: [],
tasks: [],
warnings: [],
notices: [],
assigns: %{},
mkdirs: [],
moves: %{},
args: %Igniter.Mix.Task.Args{}
]
alias Sourceror.Zipper
@type t :: %__MODULE__{
rewrite: Rewrite.t(),
issues: [String.t()],
tasks: [String.t() | {String.t(), list(String.t())}],
warnings: [String.t()],
notices: [String.t()],
assigns: map(),
mkdirs: [String.t()],
moves: %{optional(String.t()) => String.t()},
args: Igniter.Mix.Task.Args.t()
}
@type zipper_updater :: (Zipper.t() ->
{:ok, Zipper.t()}
| {:error, String.t() | [String.t()]}
| {:warning, String.t() | [String.t()]})
defimpl Inspect do
import Inspect.Algebra
def inspect(igniter, opts) do
rewrite =
concat(
"rewrite: ",
container_doc(
"#Rewrite<",
[
"#{Enum.count(igniter.rewrite.sources)} source(s)"
],
">",
opts,
fn str, _ -> str end
)
)
issues =
if Enum.empty?(igniter.issues) do
empty()
else
concat("issues: ", to_doc(igniter.issues, opts))
end
warnings =
if Enum.empty?(igniter.warnings) do
empty()
else
concat("warnings: ", to_doc(igniter.warnings, opts))
end
notices =
if Enum.empty?(igniter.notices) do
empty()
else
concat("notices: ", to_doc(igniter.notices, opts))
end
tasks =
if Enum.empty?(igniter.tasks) do
empty()
else
concat("tasks: ", to_doc(igniter.tasks, opts))
end
moves =
if Enum.empty?(igniter.moves) do
empty()
else
concat("moves: ", to_doc(igniter.moves, opts))
end
container_doc(
"#Igniter<",
[
rewrite,
issues,
warnings,
notices,
tasks,
moves
],
">",
opts,
fn str, _ -> str end
)
end
end
@doc "Returns a new igniter"
@spec new() :: t()
def new do
%__MODULE__{
rewrite:
Rewrite.new(
hooks: [Igniter.Rewrite.DotFormatterUpdater],
dot_formatter:
Rewrite.DotFormatter.read!(nil,
ignore_unknown_deps: true,
ignore_missing_sub_formatters: true
)
)
}
|> include_existing_elixir_file(".igniter.exs", required?: false)
|> parse_igniter_config()
end
def move_file(igniter, from, from, opts \\ [])
def move_file(igniter, from, from, _opts), do: igniter
def move_file(igniter, from, to, opts) do
case Enum.find(igniter.moves, fn {_key, value} -> value == from end) do
{key, _} ->
move_file(igniter.moves, key, to)
_ ->
if exists?(igniter, to) || match?({:ok, _}, Rewrite.source(igniter.rewrite, to)) do
if Keyword.get(opts, :error_if_exists?, true) do
add_issue(igniter, "Cannot move #{from} to #{to}, as #{to} already exists.")
else
igniter
end
else
igniter = include_existing_file(igniter, from)
source = Rewrite.source!(igniter.rewrite, from)
if Rewrite.Source.from?(source, :string) do
rewrite =
igniter.rewrite
|> Rewrite.drop([source.path])
|> Rewrite.put!(%{source | path: to})
%{igniter | rewrite: rewrite}
else
%{igniter | moves: Map.put(igniter.moves, from, to)}
end
end
end
end
@doc "Stores the key/value pair in `igniter.assigns`"
@spec assign(t, atom, term()) :: t()
def assign(igniter, key, value) do
%{igniter | assigns: Map.put(igniter.assigns, key, value)}
end
def assign(igniter, key_vals) do
Enum.reduce(key_vals, igniter, fn {key, value}, igniter ->
assign(igniter, key, value)
end)
end
def update_assign(igniter, key, default, fun) do
%{igniter | assigns: Map.update(igniter.assigns, key, default, fun)}
end
defp assign_private(igniter, key, value) do
%{
igniter
| assigns: Map.update(igniter.assigns, :private, %{key => value}, &Map.put(&1, key, value))
}
end
@doc "Includes all files matching the given glob, expecting them all (for now) to be elixir files."
@spec include_glob(t, Path.t() | GlobEx.t()) :: t()
def include_glob(igniter, glob) do
glob =
case glob do
%{__struct__: GlobEx} = glob ->
if Path.type(glob.source) == :absolute do
GlobEx.compile!(Path.relative_to_cwd(glob.source))
else
glob
end
string ->
GlobEx.compile!(string)
end
if igniter.assigns[:test_mode?] do
igniter.assigns[:test_files]
|> Map.keys()
|> Enum.filter(fn key ->
expanded = Path.expand(key)
glob.source == expanded || GlobEx.match?(glob, expanded)
end)
|> Enum.map(&Path.relative_to_cwd/1)
|> Enum.reject(fn path ->
Rewrite.has_source?(igniter.rewrite, path)
end)
|> Enum.map(fn path ->
source_handler = source_handler(path)
path = Path.relative_to_cwd(path)
read_source!(igniter, path, source_handler)
end)
|> Enum.reduce(igniter, fn source, igniter ->
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
end)
else
%{igniter | rewrite: Rewrite.read!(igniter.rewrite, glob)}
end
end
@doc """
Updates all files matching the given glob with the given zipper function.
Adds any new files matching that glob to the igniter first.
"""
@spec update_glob(
t,
Path.t() | GlobEx.t(),
zipper_updater
) :: t()
def update_glob(igniter, glob, func) do
glob =
case glob do
%{__struct__: GlobEx} = glob -> glob
string -> GlobEx.compile!(Path.expand(string))
end
igniter = include_glob(igniter, glob)
igniter.rewrite
|> Task.async_stream(
fn source ->
if GlobEx.match?(glob, Path.expand(source.path)) do
quoted = Rewrite.Source.get(source, :quoted)
zipper = Sourceror.Zipper.zip(quoted)
case func.(zipper) do
%Sourceror.Zipper{} = new_zipper ->
if zipper.node != new_zipper.node do
{source, {:ok, new_zipper}}
end
{:ok, %Sourceror.Zipper{} = new_zipper} ->
if zipper.node != new_zipper.node do
{source, {:ok, new_zipper}}
end
other ->
{source, other}
end
else
end
end,
timeout: :infinity
)
|> Stream.reject(fn
{:ok, nil} ->
true
_ ->
false
end)
|> Enum.reduce({igniter, []}, fn {:ok, {source, res}}, {igniter, paths} ->
case res do
{:ok, %Sourceror.Zipper{} = zipper} ->
try do
Rewrite.update!(
igniter.rewrite,
update_source(
source,
igniter,
:quoted,
Sourceror.Zipper.topmost_root(zipper),
by: :configure
)
)
|> then(&Map.put(igniter, :rewrite, &1))
|> then(fn igniter ->
{igniter, [source.path | paths]}
end)
rescue
e ->
reraise """
Failed to set the new source for the file, for `#{source.path}`
Source:
#{Igniter.Util.Debug.code_at_node(Zipper.topmost(zipper))}
Error:
#{Exception.format(:error, e)}
""",
__STACKTRACE__
end
{:error, error} ->
Rewrite.update!(
igniter.rewrite,
Rewrite.Source.add_issues(source, List.wrap(error))
)
|> then(&Map.put(igniter, :rewrite, &1))
|> then(fn igniter ->
{igniter, paths}
end)
{:warning, warning} ->
{Igniter.add_warning(igniter, warning), paths}
end
end)
|> then(fn {igniter, paths} ->
format(igniter, paths)
end)
end
@doc "Adds an issue to the issues list. Any issues will prevent writing and be displayed to the user."
@spec add_issue(t, term | list(term)) :: t()
def add_issue(igniter, issue) do
%{igniter | issues: List.wrap(issue) ++ igniter.issues}
end
@doc "Adds a warning to the warnings list. Warnings will not prevent writing, but will be displayed to the user."
@spec add_warning(t, term | list(term)) :: t()
def add_warning(igniter, warning) do
%{igniter | warnings: List.wrap(warning) ++ igniter.warnings}
end
@doc "Adds a notice to the notices list. Notices are displayed to the user once the igniter finishes running."
@spec add_notice(t, String.t()) :: t()
def add_notice(igniter, notice) do
if notice in igniter.notices do
igniter
else
%{igniter | notices: [notice] ++ igniter.notices}
end
end
@doc "Adds a task to the tasks list. Tasks will be run after all changes have been commited"
def add_task(igniter, task, argv \\ []) when is_binary(task) do
%{igniter | tasks: igniter.tasks ++ [{task, argv}]}
end
@doc """
Finds the `Igniter.Mix.Task` task by name and composes it with `igniter`.
If the task doesn't exist, a `fallback` function may be provided. This
function should accept and return the `igniter`.
## Argument handling
This function calls the task's `igniter/1` (or `igniter/2`) callback, setting
`igniter.args` using the current `igniter.args.argv_flags`. This prevents
composed tasks from accidentally consuming positional arguments. If you
wish the composed task to access additional arguments, you must explicitly
pass an `argv` list.
Additionally, you must declare other tasks you are composing with in your
task's `Igniter.Mix.Task.Info` struct using the `:composes` key. Without
this, you'll see unexpected argument errors if a flag that a composed task
uses is passed without you explicitly declaring it in your `:schema`.
## Example
def info(_argv, _parent) do
%Igniter.Mix.Task.Info{
...,
composes: [
"other.task1",
"other.task2"
]
}
def igniter(igniter) do
igniter
# other.task1 will see igniter.args.argv_flags as its args
|> Igniter.compose_task("other.task1")
# other.task2 will see an additional arg and flag
|> Igniter.compose_task("other.task2", ["arg", "--flag"] ++ igniter.argv.argv_flags)
end
"""
@spec compose_task(
t,
task :: String.t() | module(),
argv :: list(String.t()) | nil,
fallback :: (t -> t) | (t, list(String.t()) -> t) | nil
) :: t
def compose_task(igniter, task, argv \\ nil, fallback \\ nil)
def compose_task(igniter, task, argv, fallback) when is_atom(task) do
Code.ensure_compiled!(task)
original_args = igniter.args
if Igniter.Mix.Task.igniter_task?(task) do
if !task.supports_umbrella?() && Mix.Project.umbrella?() do
add_issue(igniter, "Cannot run #{inspect(task)} in an umbrella project.")
else
igniter
|> Igniter.Mix.Task.configure_and_run(task, argv || igniter.args.argv_flags)
|> Map.replace!(:args, original_args)
end
else
cond do
is_function(fallback, 1) ->
fallback.(igniter)
is_function(fallback, 2) ->
# TODO: Remove this clause when `igniter/2` is removed
fallback.(igniter, argv || igniter.args.argv)
true ->
# we don't warn because not all packages know about igniter, but they may have their own installers
# we can't assume that we should call them because they may have required arguments.
# add_issue(
# igniter,
# "#{inspect(task)} does not implement `Igniter.igniter/2` and no alternative implementation was provided."
# )
igniter
end
end
end
def compose_task(igniter, task_name, argv, fallback) do
task_name
|> Mix.Task.get()
|> case do
nil ->
cond do
is_function(fallback, 1) ->
fallback.(igniter)
is_function(fallback, 2) ->
# TODO: Remove this clause when `igniter/2` is removed
fallback.(igniter, argv || igniter.args.argv)
true ->
add_issue(
igniter,
"Task #{inspect(task_name)} could not be found."
)
end
task ->
compose_task(igniter, task, argv, fallback)
end
end
@doc """
Updates the source code of the given elixir file
"""
@spec update_elixir_file(t(), Path.t(), zipper_updater()) :: Igniter.t()
def update_elixir_file(igniter, path, func) do
if Rewrite.has_source?(igniter.rewrite, path) do
igniter
|> apply_func_with_zipper(path, func)
|> format(path)
else
if exists?(igniter, path) do
source = read_ex_source!(igniter, path)
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> format(path)
|> apply_func_with_zipper(path, func)
|> format(path)
else
add_issue(igniter, "Required #{path} but it did not exist")
end
end
end
@doc "Checks if a file exists on the file system or in the igniter."
@spec exists?(t(), Path.t()) :: boolean()
def exists?(igniter, path) do
cond do
Rewrite.has_source?(igniter.rewrite, path) ->
true
igniter.assigns[:test_mode?] ->
Map.has_key?(igniter.assigns[:test_files], path)
true ->
File.exists?(path)
end
end
@doc """
Updates a given file's `Rewrite.Source`
"""
@spec update_file(t(), Path.t(), (Rewrite.Source.t() -> Rewrite.Source.t())) :: t()
def update_file(igniter, path, updater, opts \\ []) do
source_handler = source_handler(path, opts)
if Rewrite.has_source?(igniter.rewrite, path) do
%{igniter | rewrite: Rewrite.update!(igniter.rewrite, path, updater)}
else
if exists?(igniter, path) do
source = read_source!(igniter, path, source_handler)
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> maybe_format(path, true, Keyword.put(opts, :source_handler, source_handler))
|> Map.update!(:rewrite, fn rewrite ->
source = Rewrite.source!(rewrite, path)
Rewrite.update!(rewrite, path, updater.(source))
end)
else
add_issue(igniter, "Required #{path} but it did not exist")
end
end
end
@deprecated "Use `include_existing_file/3` instead"
@spec include_existing_elixir_file(t(), Path.t(), opts :: Keyword.t()) :: t()
def include_existing_elixir_file(igniter, path, opts \\ []) do
include_existing_file(igniter, path, Keyword.put(opts, :source_handler, Rewrite.Source.Ex))
end
@doc "Includes the given file in the project, expecting it to exist. Does nothing if its already been added."
@spec include_existing_file(t(), Path.t(), opts :: Keyword.t()) :: t()
def include_existing_file(igniter, path, opts \\ []) do
required? = Keyword.get(opts, :required?, false)
source_handler = source_handler(path, opts)
if Rewrite.has_source?(igniter.rewrite, path) do
igniter
else
if exists?(igniter, path) do
source = read_source!(igniter, path, source_handler)
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> maybe_format(path, false, opts)
else
if required? do
add_issue(igniter, "Required #{path} but it did not exist")
else
igniter
end
end
end
end
@deprecated "Use `include_or_create_file/3` instead"
@spec include_or_create_elixir_file(t(), Path.t(), contents :: String.t()) :: t()
def include_or_create_elixir_file(igniter, path, contents \\ "") do
include_or_create_file(igniter, path, contents)
end
@doc "Includes or creates the given file in the project with the provided contents. Does nothing if its already been added."
@spec include_or_create_file(t(), Path.t(), contents :: String.t()) :: t()
def include_or_create_file(igniter, path, contents \\ "") do
if Rewrite.has_source?(igniter.rewrite, path) do
igniter
else
source_handler = source_handler(path)
source =
try do
read_source!(igniter, path, source_handler)
rescue
_ ->
""
|> Rewrite.Source.Ex.from_string(path: path)
|> update_source(igniter, :content, contents, by: :file_creator)
end
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> maybe_format(path, true, source_handler: source_handler)
end
end
@doc "Creates the given file in the project with the provided string contents, or updates it with a function of type `zipper_updater()` if it already exists."
@spec create_or_update_elixir_file(t(), Path.t(), String.t(), zipper_updater()) :: Igniter.t()
def create_or_update_elixir_file(igniter, path, contents, updater) do
if Rewrite.has_source?(igniter.rewrite, path) do
igniter
|> update_elixir_file(path, updater)
else
{created?, source} =
try do
{false, read_ex_source!(igniter, path)}
rescue
_ ->
{true,
""
|> Rewrite.Source.Ex.from_string(path)
|> update_source(igniter, :content, contents, by: :file_creator)}
end
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> format(path)
|> then(fn igniter ->
if created? do
igniter
else
update_elixir_file(igniter, path, updater)
end
end)
end
end
@doc "Creates the given file in the project with the provided string contents, or updates it with a function as in `update_file/3` (or with `zipper_updater()` for elixir files) if it already exists."
def create_or_update_file(igniter, path, contents, updater) do
if Rewrite.has_source?(igniter.rewrite, path) do
igniter
|> update_file(path, updater)
else
source_handler = source_handler(path)
{created?, source} =
try do
{false, read_source!(igniter, path, source_handler)}
rescue
_ ->
{true,
""
|> source_handler.from_string(path)
|> update_source(igniter, :content, contents, by: :file_creator)}
end
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> maybe_format(path, true, source_handler: source_handler)
|> then(fn igniter ->
if created? do
igniter
else
update_file(igniter, path, updater)
end
end)
end
end
@deprecated "Use `create_new_file/4`"
@spec create_new_elixir_file(t(), Path.t(), String.t()) :: Igniter.t()
def create_new_elixir_file(igniter, path, contents \\ "", opts \\ []) do
create_new_file(
igniter,
path,
contents,
Keyword.put(opts, :source_handler, Rewrite.Source.Ex)
)
end
@doc """
Copies an EEx template file from the source path to the target path.
Accepts the same options as `create_new_file/4`.
"""
@spec copy_template(
igniter :: Igniter.t(),
source :: Path.t(),
target :: Path.t(),
assigns :: Keyword.t(),
opts :: Keyword.t()
) :: Igniter.t()
def copy_template(igniter, source, target, assigns, opts \\ []) do
contents = EEx.eval_file(source, assigns: assigns)
create_new_file(igniter, target, contents, opts)
end
@doc """
Creates a folder in the project.
"""
@spec mkdir(t(), Path.t()) :: Igniter.t()
def mkdir(igniter, path) do
current_dir = Path.expand(".")
target_path = Path.expand(path)
if String.starts_with?(target_path, current_dir) do
%{igniter | mkdirs: List.wrap(path) ++ igniter.mkdirs}
else
add_issue(igniter, "Igniter.mkdir invalid path: #{path} is outside the current directory.")
end
end
@doc """
Creates a new file in the project with the provided string contents. Adds an error if it already exists.
## Options
- `:on_exists` - The action to take if the file already exists. Can be
- `:error` (default) - Adds an error that prevents any eventual write.
- `:warning` - Warns when writing but continues (without overwriting)
- `:skip` - Skips writing the file without a warning
- `:overwrite` - Warns when writing and overwrites the content with the new content
"""
@spec create_new_file(t(), Path.t(), String.t()) :: Igniter.t()
def create_new_file(igniter, path, contents \\ "", opts \\ []) do
source_handler = source_handler(path, opts)
{igniter, source} =
try do
source = read_source!(igniter, path, source_handler)
source = update_source(source, igniter, :content, contents)
{already_exists(igniter, path, Keyword.get(opts, :on_exists, :error)), source}
rescue
_ ->
has_source? =
Rewrite.has_source?(igniter.rewrite, path)
source =
""
|> source_handler.from_string(path: path)
|> update_source(igniter, :content, contents, by: :file_creator)
if has_source? do
{already_exists(igniter, path, Keyword.get(opts, :on_exists, :error)), source}
else
{igniter, source}
end
end
sources =
case opts[:on_exists] do
:overwrite ->
Map.put(igniter.rewrite.sources, path, source)
_ ->
Map.put_new(igniter.rewrite.sources, path, source)
end
%{
igniter
| rewrite: %{igniter.rewrite | sources: sources}
}
|> maybe_format(path, true, opts)
end
defp already_exists(igniter, path, :error) do
Igniter.add_issue(igniter, "#{path}: File already exists")
end
defp already_exists(igniter, path, :warning) do
Igniter.add_warning(igniter, "#{path}: File already exists")
end
defp already_exists(igniter, _path, _) do
igniter
end
defp maybe_format(igniter, path, default_bool, opts) do
if source_handler(path, opts) == Rewrite.Source.Ex and
Keyword.get(opts, :format?, default_bool) do
format(igniter, path)
else
igniter
end
end
defp source_handler(path, opts \\ []) do
Keyword.get_lazy(opts, :source_handler, fn ->
if Path.extname(path) in Rewrite.Source.Ex.extensions() do
Rewrite.Source.Ex
else
Rewrite.Source
end
end)
end
@doc """
Applies the current changes to the `mix.exs` in the Igniter and fetches dependencies.
Returns the remaining changes in the Igniter if successful.
## Options
* `:error_on_abort?` - If `true`, raises an error if the user aborts the operation. Returns the original igniter if not.
* `:yes` - If `true`, automatically applies the changes without prompting the user.
"""
def apply_and_fetch_dependencies(igniter, opts \\ []) do
if igniter.assigns[:test_mode?] do
raise "Cannot use `Igniter.apply_and_fetch_dependencies/1-2` in test mode"
end
if opts[:force?] ||
(!igniter.assigns[:private][:refused_fetch_dependencies?] &&
has_changes?(igniter, ["mix.exs"])) do
source = Rewrite.source!(igniter.rewrite, "mix.exs")
original_quoted = Rewrite.Source.get(source, :quoted, 1)
original_zipper = Zipper.zip(original_quoted)
quoted = Rewrite.Source.get(source, :quoted)
zipper = Zipper.zip(quoted)
with {:ok, original_zipper} <-
Igniter.Code.Function.move_to_defp(original_zipper, :deps, 0),
{:ok, zipper} <- Igniter.Code.Function.move_to_defp(zipper, :deps, 0) do
quoted_with_only_deps_change =
original_zipper
|> Igniter.Code.Common.replace_code(clean_comments(zipper.node))
|> Zipper.topmost()
|> Zipper.node()
source = update_source(source, igniter, :quoted, quoted_with_only_deps_change)
rewrite = Rewrite.update!(igniter.rewrite, source)
if opts[:force?] || changed?(source) do
message =
opts[:message] ||
if opts[:error_on_abort?] do
"These dependencies #{IO.ANSI.red()}must#{IO.ANSI.reset()} be installed before continuing. Modify mix.exs and install?"
else
"These dependencies #{IO.ANSI.yellow()}should#{IO.ANSI.reset()} be installed before continuing. Modify mix.exs and install?"
end
if opts[:yes] || opts[:yes_to_deps] || !changed?(source) ||
diff_and_yes?(igniter, [source], opts, message) do
rewrite =
case Rewrite.write(rewrite, "mix.exs", :force) do
{:ok, rewrite} -> rewrite
{:error, error} -> raise error
end
source = Rewrite.source!(rewrite, "mix.exs")
source = update_source(source, igniter, :quoted, quoted)
igniter =
igniter
|> Map.update!(:rewrite, &Rewrite.update!(&1, source))
|> accepted_once()
if Keyword.get(opts, :fetch?, true) do
Igniter.Util.Install.get_deps!(
igniter,
Keyword.put_new(opts, :operation, "installing new dependencies")
)
else
igniter
end
else
if opts[:error_on_abort?] do
raise "Aborted by the user."
else
assign_private(igniter, :refused_fetch_dependencies?, true)
end
end
else
igniter
end
else
_ ->
display_diff([source], opts)
message =
"Dependency changes require updating `mix.exs` before continuing.\nModify `mix.exs` and install?"
if Igniter.Util.IO.yes?(message) do
rewrite =
case Rewrite.write(igniter.rewrite, "mix.exs", :force) do
{:ok, rewrite} -> rewrite
{:error, error} -> raise error
end
igniter =
Igniter.Util.Install.get_deps!(
igniter,
Keyword.put_new(opts, :operation, "installing new dependencies")
)
%{igniter | rewrite: rewrite}
else
if opts[:error_on_abort?] do
raise "Aborted by the user."
else
assign_private(igniter, :refused_fetch_dependencies?, true)
end
end
end
else
igniter
end
end
defp clean_comments(node) do
case node do
{f, meta, a} ->
{f, Keyword.merge(meta, leading_comments: [], trailing_comments: []), a}
other ->
other
end
end
defp diff_and_yes?(igniter, sources, opts, message) do
display_diff(sources, opts)
Igniter.Util.IO.yes?(
message_with_git_warning(
igniter,
Keyword.put(opts, :message, message)
)
)
end
@doc """
Installs a package as if calling `mix igniter.install`
See `mix igniter.install` for information on the package format.
## Options
- `append?` - If `true`, appends the package to the existing list of packages instead of prepending. Defaults to `false`.
## Examples
Igniter.install(igniter, "ash")
Igniter.install(igniter, "ash_authentication@2.0", ["--authentication-strategies", "password,magic_link"])
"""
def install(igniter, package, argv \\ [], opts \\ []) when is_binary(package) do
Igniter.Util.Install.install(List.wrap(package), argv, igniter, opts)
end
@doc "This function stores in the igniter if its been run before, so it is only run once, which is expensive."
def include_all_elixir_files(igniter) do
if igniter.assigns[:private][:included_all_elixir_files?] do
igniter
else
igniter
|> Igniter.Project.IgniterConfig.get(:source_folders)
|> Enum.reduce(igniter, fn source_folder, igniter ->
include_glob(igniter, Path.join(source_folder, "/**/*.{ex,exs}"))
end)
|> include_glob("{test,config}/**/*.{ex,exs}")
|> assign_private(:included_all_elixir_files?, true)
end
end
@doc "Runs an update over all elixir files"
def update_all_elixir_files(igniter, updater) do
if igniter.assigns[:private][:included_all_elixir_files?] do
igniter
else
igniter
|> Igniter.Project.IgniterConfig.get(:source_folders)
|> Enum.reduce(igniter, fn source_folder, igniter ->
update_glob(igniter, Path.join(source_folder, "/**/*.{ex,exs}"), updater)
end)
|> update_glob("{test,config}/**/*.{ex,exs}", updater)
|> assign_private(:included_all_elixir_files?, true)
end
end
@doc """
Returns whether the current Igniter has pending changes.
"""
def has_changes?(igniter, paths \\ nil) do
paths =
if paths do
Enum.map(paths, &Path.relative_to_cwd/1)
end
igniter.rewrite
|> Rewrite.sources()
|> then(fn sources ->
if paths do
sources
|> Enum.filter(&(&1.path in paths))
else
sources
end
end)
|> Enum.any?(fn source ->
Rewrite.Source.from?(source, :string) || Rewrite.Source.updated?(source)
end)
end
@doc """
Executes or dry-runs a given Igniter.
"""
def do_or_dry_run(igniter, opts \\ []) do
if igniter.assigns[:test_mode?] do
raise ArgumentError,
"Must `Igniter.Test.apply/1` instead of `Igniter.do_or_dry_run` when running in `test_mode?`."
end
igniter = prepare_for_write(igniter)
title = opts[:title] || "Igniter"
halt_if_fails_check!(igniter, title, opts)
case igniter do
%{issues: []} ->
result_of_dry_run =
if has_changes?(igniter) do
if opts[:dry_run] || !opts[:yes] do
Mix.shell().info("\n#{IO.ANSI.green()}#{title}#{IO.ANSI.reset()}:")
display_diff(Rewrite.sources(igniter.rewrite), opts)
end
:dry_run_with_changes
else
if !(opts[:quiet_on_no_changes?] || opts[:yes]) do
Mix.shell().info("\n#{title}:\n\n No proposed content changes!\n")
display_notices(igniter)
end
:dry_run_with_no_changes
end
display_warnings(igniter, title)
display_mkdirs(igniter)
display_moves(igniter)
display_tasks(igniter, result_of_dry_run, opts)
if opts[:dry_run] ||
(result_of_dry_run == :dry_run_with_no_changes && Enum.empty?(igniter.tasks) &&
Enum.empty?(igniter.moves)) do
result_of_dry_run
else
if opts[:yes] ||
Igniter.Util.IO.yes?(message_with_git_warning(igniter, opts)) do
igniter.rewrite
|> Enum.any?(fn source ->
Rewrite.Source.from?(source, :string) || Rewrite.Source.updated?(source)
end)
|> Kernel.||(!Enum.empty?(igniter.tasks))
|> Kernel.||(!Enum.empty?(igniter.moves))
|> if do
igniter.rewrite
|> Rewrite.write_all()
|> case do
{:ok, _result} ->
if !Enum.empty?(igniter.tasks) do
Mix.shell().cmd("mix deps.get")
end
igniter.mkdirs
|> Enum.map(&Path.expand(&1, "."))
|> Enum.uniq()
|> Enum.each(fn path ->
File.mkdir_p!(path)
end)
igniter.moves
|> Enum.each(fn {from, to} ->
File.mkdir_p!(Path.dirname(to))
File.rename!(from, to)
end)
igniter.tasks
|> Enum.each(fn {task, args} ->
Mix.shell().cmd("mix #{task} #{Enum.join(args, " ")}")
end)
display_notices(igniter)
:changes_made
{:error, error, rewrite} ->
igniter
|> Map.put(:rewrite, rewrite)
|> Igniter.add_issue(error)
|> display_issues()
:issues
end
else
:no_changes
end
else
:changes_aborted
end
end
igniter ->
display_issues(igniter)
:issues
end
end
defp accepted_once(igniter) do
Igniter.assign(
igniter,
:private,
Map.put(igniter.assigns[:private] || %{}, :accepted_once, true)
)
end
defp halt_if_fails_check!(igniter, title, opts) do
cond do
!opts[:check] ->
:ok
!Enum.empty?(igniter.warnings) ->
Mix.shell().error("Warnings would have been emitted and the --check flag was specified.")
display_warnings(igniter, title)
System.halt(2)
!Enum.empty?(igniter.issues) ->
Mix.shell().error("Errors would have been emitted and the --check flag was specified.")
display_issues(igniter)
System.halt(3)
!Enum.empty?(igniter.tasks) ->
Mix.shell().error("Tasks would have been run and the --check flag was specified.")
display_tasks(igniter, :dry_run_with_no_changes, [])
System.halt(3)
!Enum.empty?(igniter.moves) ->
Mix.shell().error("Files would have been moved and the --check flag was specified.")
display_moves(igniter)
System.halt(3)
Igniter.has_changes?(igniter) ->
Mix.shell().error(
"Changes have been made to the project and the --check flag was specified."
)
display_diff(Rewrite.sources(igniter.rewrite), opts)
System.halt(1)
true ->
:ok
end
end
defp message_with_git_warning(igniter, opts) do
message = opts[:message] || "Proceed with changes?"
if opts[:dry_run] || opts[:yes] || igniter.assigns[:test_mode?] || !has_changes?(igniter) do
message
else
if Map.get(igniter.assigns, :prompt_on_git_changes?, true) and
!igniter.assigns[:private][:accepted_once] do
case check_git_status() do
{:dirty, output} ->
"""
#{IO.ANSI.red()}Warning! Uncommitted git changes detected in the project. #{IO.ANSI.reset()}
You #{IO.ANSI.yellow()}may#{IO.ANSI.reset()} want to save these changes and rerun this command.
This ensures that you can run `#{IO.ANSI.red()}git reset#{IO.ANSI.reset()}` to undo the changes.
Output of `#{IO.ANSI.green()}git status -s --porcelain#{IO.ANSI.reset()}`:
#{output}
#{message}
"""
_ ->
message
end
else
message
end
end
end
defp display_diff(sources, opts) do
if !opts[:yes] do
Mix.shell().info(diff(sources))
end
end
@doc false
def diff(sources, opts \\ []) do
color? = Keyword.get(opts, :color?, true)
Enum.map_join(sources, fn source ->
source =
case source do
{_, source} -> source
source -> source
end
cond do
Rewrite.Source.from?(source, :string) &&
String.valid?(Rewrite.Source.get(source, :content)) ->
content_lines =
source
|> Rewrite.Source.get(:content)
|> String.split("\n")
space_padding =
content_lines
|> length()
|> to_string()
|> String.length()
diffish_looking_text =
content_lines
|> Enum.with_index(1)
|> Enum.map_join(fn {line, line_number} ->
IO.ANSI.format(
[
String.pad_trailing(to_string(line_number), space_padding),
" ",
:yellow,
"|",
:green,
line,
"\n"
],
color?
)
end)
if String.trim(diffish_looking_text) != "" do
"""
Create: #{Rewrite.Source.get(source, :path)}
#{diffish_looking_text}
"""
else
""
end
String.valid?(Rewrite.Source.get(source, :content)) ->
diff = Rewrite.Source.diff(source, color: color?) |> IO.iodata_to_binary()
if String.trim(diff) != "" do
"""
Update: #{Rewrite.Source.get(source, :path)}
#{diff}
"""
else
""
end
!String.valid?(Rewrite.Source.get(source, :content)) ->
"""
Create: #{Rewrite.Source.get(source, :path)}
(content diff can't be displayed)
"""
:else ->
""
end
end)
end
@doc false
def format(igniter, adding_paths, reevaluate_igniter_config? \\ true) do
igniter =
igniter
|> include_existing_elixir_file("config/config.exs", require?: false)
|> include_existing_elixir_file("config/#{Mix.env()}.exs", require?: false)
if adding_paths &&
Enum.any?(List.wrap(adding_paths), &(Path.basename(&1) == ".formatter.exs")) do
format(igniter, nil, false)
|> reevaluate_igniter_config(adding_paths, reevaluate_igniter_config?)
else
igniter =
"**/.formatter.exs"
|> Path.wildcard()
|> Enum.reduce(igniter, fn path, igniter ->
Igniter.include_existing_file(igniter, path)
end)
igniter =
if exists?(igniter, ".formatter.exs") do
Igniter.include_existing_file(igniter, ".formatter.exs")
else
igniter
end
rewrite = igniter.rewrite
dot_formatter = Rewrite.dot_formatter(rewrite)
rewrite =
Enum.reduce(rewrite, rewrite, fn source, rewrite ->
path = source |> Rewrite.Source.get(:path)
if is_nil(adding_paths) || path in List.wrap(adding_paths) do
source =
try do
formatted =
with_evaled_configs(rewrite, fn ->
source
|> Rewrite.Source.format!(dot_formatter: dot_formatter)
|> Rewrite.Source.get(:content)
end)
update_source(source, igniter, :content, formatted)
rescue
e ->
Rewrite.Source.add_issue(source, """
Igniter would have produced invalid syntax.
This is almost certainly a bug in Igniter, or in the implementation
of the task/function you are using.
#{Exception.format(:error, e, __STACKTRACE__)}
""")
end
Rewrite.update!(rewrite, source)
else
rewrite
end
end)
%{igniter | rewrite: rewrite}
|> reevaluate_igniter_config(adding_paths, reevaluate_igniter_config?)
end
end
defp reevaluate_igniter_config(igniter, adding_paths, true) do
if is_nil(adding_paths) || ".igniter.exs" in List.wrap(adding_paths) do
parse_igniter_config(igniter)
else
igniter
end
end
defp reevaluate_igniter_config(igniter, _adding_paths, false) do
igniter
end
# for now we only eval `config.exs`
defp with_evaled_configs(rewrite, fun) do
[
Rewrite.source(rewrite, "config/config.exs"),
Rewrite.source(rewrite, "config/#{Mix.env()}.exs")
]
|> Enum.flat_map(fn
{:ok, source} ->
[Rewrite.Source.get(source, :quoted)]
_ ->
[]
end)
|> case do
[] ->
fun.()
contents ->
to_set =
{:__block__, [], contents}
|> Sourceror.Zipper.zip()
# replace with nil
|> Igniter.Code.Common.remove(
&Igniter.Code.Function.function_call?(&1, :import_config, 1)
)
|> Zipper.topmost_root()
|> Sourceror.to_string()
|> then(&Config.Reader.eval!("config/config.exs", &1, env: Mix.env()))
restore =
to_set
|> Keyword.keys()
|> Enum.map(fn key ->
{key, Application.get_all_env(key)}
end)
try do
Application.put_all_env(to_set)
fun.()
after
Application.put_all_env(restore)
end
end
end
defp apply_func_with_zipper(igniter, path, func) do
source = Rewrite.source!(igniter.rewrite, path)
quoted = Rewrite.Source.get(source, :quoted)
zipper = Sourceror.Zipper.zip(quoted)
res =
case func.(zipper) do
%Sourceror.Zipper{} = zipper ->
{:ok, zipper}
other ->
other
end
case res do
{:ok, %Sourceror.Zipper{} = zipper} ->
try do
Rewrite.update!(
igniter.rewrite,
update_source(
source,
igniter,
:quoted,
Sourceror.Zipper.root(zipper),
by: :configure
)
)
|> then(&Map.put(igniter, :rewrite, &1))
rescue
e ->
reraise e, __STACKTRACE__
end
{:error, error} ->
Rewrite.update!(
igniter.rewrite,
Rewrite.Source.add_issues(source, List.wrap(error))
)
|> then(&Map.put(igniter, :rewrite, &1))
{:warning, warning} ->
Igniter.add_warning(igniter, warning)
end
end
defp read_ex_source!(igniter, path) do
read_source!(igniter, path, Rewrite.Source.Ex)
end
defp read_source!(igniter, path, source_handler) do
if igniter.assigns[:test_mode?] do
if content = igniter.assigns[:test_files][path] do
source_handler.from_string(content, path: path)
|> Map.put(:from, :file)
else
raise "File #{path} not found in test files."
end
else
source_handler.read!(path)
end
end
@doc false
def prepare_for_write(igniter) do
source_issues =
Enum.flat_map(igniter.rewrite, fn source ->
changed_issues =
if igniter.assigns[:test_mode?] do
[]
else
if Rewrite.Source.file_changed?(source) do
["File has been changed since it was originally read."]
else
[]
end
end
issues = Enum.uniq(changed_issues ++ Rewrite.Source.issues(source))
case issues do
[] ->
[]
issues ->
Enum.map(issues, fn issue ->
"#{source.path}: #{issue}"
end)
end
end)
needs_test_support? =
Enum.any?(igniter.rewrite, fn source ->
Path.extname(source.path) == ".ex" &&
source.path
|> Path.split()
|> List.starts_with?(["test", "support"])
end)
%{
igniter
| issues: Enum.uniq(igniter.issues ++ source_issues),
warnings: Enum.uniq(igniter.warnings),
tasks: Enum.uniq(igniter.tasks)
}
|> Igniter.Project.Module.move_files()
|> remove_unchanged_files()
|> then(fn igniter ->
if needs_test_support? do
Igniter.Project.Test.ensure_test_support(igniter)
else
igniter
end
end)
end
defp remove_unchanged_files(igniter) do
igniter.rewrite
|> Enum.flat_map(fn source ->
if Rewrite.Source.from?(source, :string) || changed?(source) do
[]
else
[source.path]
end
end)
|> then(fn paths ->
%{igniter | rewrite: Rewrite.drop(igniter.rewrite, paths)}
end)
end
defp parse_igniter_config(igniter) do
case Rewrite.source(igniter.rewrite, ".igniter.exs") do
{:error, _} ->
assign(igniter, :igniter_exs, [])
{:ok, source} ->
{igniter_exs, _} = Rewrite.Source.get(source, :quoted) |> Code.eval_quoted()
assign(
igniter,
:igniter_exs,
Keyword.update(igniter_exs, :extensions, [], fn extensions ->
Enum.map(extensions, fn
{extension, opts} ->
{extension, opts}
extension ->
{extension, []}
end)
end)
)
end
end
@doc "Returns true if any of the files specified in `paths` have changed."
@spec changed?(t(), String.t() | list(String.t())) :: boolean()
def changed?(%Igniter{} = igniter, paths) do
paths = List.wrap(paths)
igniter.rewrite
|> Rewrite.sources()
|> Enum.filter(&(&1.path in paths))
|> Enum.any?(&changed?/1)
end
@doc "Returns true if the igniter or source provided has changed"
@spec changed?(t() | Rewrite.Source.t()) :: boolean()
def changed?(%Igniter{} = igniter) do
igniter.rewrite
|> Rewrite.sources()
|> Enum.any?(&changed?/1)
end
def changed?(%Rewrite.Source{} = source) do
diff = Rewrite.Source.diff(source) |> IO.iodata_to_binary()
String.trim(diff) != ""
end
@doc false
def update_source(%Rewrite.Source{} = source, %Igniter{} = igniter, key, value, opts \\ []) do
opts = Keyword.put_new(opts, :dot_formatter, Rewrite.dot_formatter(igniter.rewrite))
Rewrite.Source.update(source, key, value, opts)
end
@doc false
def display_issues(igniter) do
igniter.issues
|> Enum.reverse()
|> Enum.map(fn error ->
["* ", :red, format_error(error)]
end)
|> display_list([:red, "Issues:"])
end
@doc false
def display_warnings(igniter, title) do
igniter.warnings
|> Enum.reverse()
|> Enum.map(fn error ->
["* ", :yellow, indent(format_error(error), 2)]
end)
|> display_list([title, " - ", :yellow, "Warnings:"])
end
@doc false
def display_notices(igniter) do
igniter.notices
|> Enum.reverse()
|> Enum.map(fn notice ->
["* ", :green, indent(notice, 2), :reset]
end)
|> display_list(["Notices: "])
end
@doc false
def display_mkdirs(igniter) do
igniter.mkdirs
|> Enum.map(&Path.expand(&1, ""))
|> Enum.uniq()
|> Enum.sort()
|> Enum.map(fn path ->
if not File.exists?(path) do
[:green, path]
end
end)
|> Enum.reject(&is_nil/1)
|> display_list("These folders will be created:")
end
defp indent(string, count) do
string
|> String.split("\n")
|> Enum.map_join("\n", &(String.duplicate(" ", count) <> &1))
|> String.trim_leading(" ")
end
@doc false
def display_moves(igniter) do
igniter.moves
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(fn {from, to} ->
[:red, Path.relative_to_cwd(from), :reset, ": ", :green, to]
end)
|> display_list("These files will be moved:")
end
@doc false
def display_tasks(igniter, result_of_dry_run, opts) do
if !opts[:yes] do
title =
if result_of_dry_run == :dry_run_with_no_changes do
"These tasks will be run:"
else
"These tasks will be run after the above changes:"
end
igniter.tasks
|> Enum.map(fn {task, args} ->
["* ", :red, task, " ", :yellow, Enum.intersperse(args, " ")]
end)
|> display_list(title)
end
end
@spec display_list(IO.ANSI.ansidata(), IO.ANSI.ansidata()) :: :ok
defp display_list([], _title), do: :ok
defp display_list(list, title) do
title = [IO.ANSI.format(title), "\n\n"]
formatted_list = Enum.map_join(list, "\n", &IO.ANSI.format/1)
Mix.shell().info(["\n", title, formatted_list, "\n"])
end
defp format_error(%{__exception__: true} = exception) do
Exception.format(:error, exception)
end
defp format_error(error) when is_binary(error), do: error
defp check_git_status do
case System.cmd("git", ["status", "-s", "--porcelain"], stderr_to_stdout: true) do
{"", _} ->
:clean
{output, 0} ->
{:dirty, output}
_ ->
:error
end
rescue
_ ->
:unavailable
end
end