This commit is contained in:
Zach Daniel 2024-05-27 23:30:41 -04:00
commit 7ae0261a28
19 changed files with 1415 additions and 0 deletions

4
.formatter.exs Normal file
View file

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
igniter-*.tar
# Temporary files, for example, from tests.
/tmp/

24
README.md Normal file
View file

@ -0,0 +1,24 @@
# Igniter
**TODO: Add description**
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `igniter` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:igniter, "~> 0.1.0"}
]
end
```
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/igniter>.
# TODO list:
- [ ] properly parse args, not `"--dry-run" in argv`. Do we want to have some kind of "maybe parsed" args structure so we can call tasks with `argv` or one of those? Maybe.

18
lib/args.ex Normal file
View file

@ -0,0 +1,18 @@
defmodule Igniter.Args do
def validate_present_and_underscored(igniter, opts, option, message) do
cond do
!opts[option] ->
{:error, Igniter.add_issue(igniter, message)}
not (Macro.underscore(opts[option]) == opts[option]) ->
{:error,
Igniter.add_issue(
igniter,
"Must provide the #{option} in snake_case. Did you mean `#{Macro.underscore(opts[:option])}`"
)}
true ->
{:ok, opts[option]}
end
end
end

580
lib/common.ex Normal file
View file

@ -0,0 +1,580 @@
defmodule Igniter.Common do
alias Sourceror.Zipper
def find(zipper, direction \\ :next, pred) do
Zipper.find(zipper, direction, fn thing ->
try do
pred.(thing)
rescue
FunctionClauseError ->
false
end
end)
end
defmacro node_matches_pattern?(zipper, pattern) do
quote do
ast =
unquote(zipper)
|> Igniter.Common.maybe_enter_block()
|> Zipper.subtree()
|> Zipper.root()
match?(unquote(pattern), ast)
end
end
defmacro find_pattern(zipper, direction \\ :next, pattern) do
quote do
Sourceror.Zipper.find(unquote(zipper), unquote(direction), fn
unquote(pattern) ->
true
_ ->
false
end)
end
end
defmacro argument_matches_pattern?(zipper, index, pattern) do
quote do
Igniter.Common.argument_matches_predicate?(
unquote(zipper),
unquote(index),
&match?(unquote(pattern), &1)
)
end
end
def puts_code_at_node(zipper) do
zipper
|> Zipper.subtree()
|> Zipper.root()
|> Sourceror.to_string()
|> IO.puts()
zipper
end
def add_code(zipper, new_code) do
current_code =
zipper
|> Zipper.subtree()
|> Zipper.root()
|> IO.inspect()
case current_code do
{:__block__, block_meta, stuff} ->
Zipper.replace(zipper, {:__block__, block_meta, stuff ++ [new_code]})
code ->
Zipper.replace(zipper, {:__block__, [], [code, new_code]})
end
end
def put_in_keyword(zipper, path, value, updater \\ nil) do
updater = updater || fn _ -> value end
do_put_in_keyword(zipper, path, value, updater)
end
defp do_put_in_keyword(zipper, [key], value, updater) do
set_keyword_key(zipper, key, value, updater)
end
defp do_put_in_keyword(zipper, [key | rest], value, updater) do
if node_matches_pattern?(zipper, value when is_list(value)) do
case find_list_item(zipper, fn item ->
if is_tuple?(item) do
first_elem = tuple_elem(item, 0)
first_elem && node_matches_pattern?(first_elem, ^key)
end
end) do
nil ->
value = keywordify(rest, value)
prepend_to_list(
zipper,
{{:__block__, [format: :keyword], [key]}, {:__block__, [], [value]}}
)
zipper ->
zipper
|> tuple_elem(1)
|> case do
nil ->
nil
zipper ->
do_put_in_keyword(zipper, rest, value, updater)
end
end
end
end
def set_keyword_key(zipper, key, value, updater) do
if node_matches_pattern?(zipper, value when is_list(value)) do
case find_list_item(zipper, fn item ->
if is_tuple?(item) do
first_elem = tuple_elem(item, 0)
first_elem && node_matches_pattern?(first_elem, ^key)
end
end) do
nil ->
prepend_to_list(
zipper,
{{:__block__, [format: :keyword], [key]}, {:__block__, [], [value]}}
)
zipper ->
zipper
|> tuple_elem(1)
|> case do
nil ->
nil
zipper ->
updater.(zipper)
end
end
end
end
def find_function_call_in_current_scope(zipper, name, arity, predicate \\ fn _ -> true end) do
case Zipper.down(zipper) do
nil ->
nil
zipper ->
find_right(zipper, fn zipper ->
is_function_call(zipper, name, arity) && predicate.(zipper)
end)
end
end
def is_function_call(zipper, name, arity) do
zipper
|> Zipper.subtree()
|> Zipper.root()
|> case do
{^name, _, args} ->
Enum.count(args) == arity
{{^name, _, context}, _, args} when is_atom(context) ->
Enum.count(args) == arity
{:|>, _, [{^name, _, context} | rest]} when is_atom(context) ->
Enum.count(rest) == arity - 1
{:|>, _, [^name | rest]} ->
Enum.count(rest) == arity - 1
_ ->
false
end
end
def update_nth_argument(zipper, index, func) do
if is_pipeline?(zipper) do
if index == 0 do
zipper
|> Zipper.down()
|> case do
nil ->
nil
zipper ->
func.(zipper)
end
else
zipper
|> Zipper.down()
|> case do
nil ->
nil
zipper ->
zipper
|> Zipper.rightmost()
|> Zipper.down()
|> case do
nil ->
nil
zipper ->
zipper
|> nth_right(index)
|> case do
nil ->
nil
nth ->
func.(nth)
end
end
end
end
else
zipper
|> Zipper.down()
|> case do
nil ->
nil
zipper ->
zipper
|> nth_right(index)
|> case do
nil ->
nil
nth ->
func.(nth)
end
end
end
end
def argument_matches_predicate?(zipper, index, func) do
if is_pipeline?(zipper) do
if index == 0 do
zipper
|> Zipper.down()
|> case do
nil -> nil
zipper -> func.(zipper)
end
else
zipper
|> Zipper.down()
|> case do
nil ->
nil
zipper ->
zipper
|> Zipper.rightmost()
|> Zipper.down()
|> case do
nil ->
nil
zipper ->
zipper
|> nth_right(index - 1)
|> maybe_enter_block()
|> Zipper.subtree()
|> Zipper.root()
|> func.()
end
end
end
else
zipper
|> Zipper.down()
|> case do
nil ->
false
zipper ->
zipper
|> nth_right(index)
|> maybe_enter_block()
|> Zipper.subtree()
|> Zipper.root()
|> func.()
end
end
end
def is_pipeline?(zipper) do
zipper
|> Zipper.subtree()
|> Zipper.root()
|> case do
{:|>, _, _} -> true
_ -> false
end
end
def move_to_module_using(zipper, module) do
split_module =
module
|> Module.split()
|> Enum.map(&String.to_atom/1)
with zipper when not is_nil(zipper) <- find_pattern(zipper, {:defmodule, _, [_, _]}),
subtree <- Zipper.subtree(zipper),
subtree <- subtree |> Zipper.down() |> Zipper.rightmost(),
subtree <- remove_module_definitions(subtree),
found when not is_nil(found) <-
find(subtree, fn
{:use, _, [^module]} ->
true
{:use, _, [{:__aliases__, _, ^split_module}]} ->
true
end),
{:ok, zipper} <- move_to_do_block(zipper) do
{:ok, zipper}
else
_ ->
:error
end
end
# aliases will confuse this, but that is a later problem :)
def equal_modules?(zipper, module) do
root =
zipper
|> Zipper.subtree()
|> Zipper.root()
do_equal_modules?(root, module)
end
defp do_equal_modules?(left, left), do: true
defp do_equal_modules?({:__aliases__, _, mod}, {:__aliases__, _, mod}), do: true
defp do_equal_modules?({:__aliases__, _, mod}, right) when is_atom(right) do
Module.concat(mod) == right
end
defp do_equal_modules?(left, {:__aliases__, _, mod}) when is_atom(left) do
Module.concat(mod) == left
end
defp do_equal_modules?(_, _), do: false
def move_to_defp(zipper, fun, arity) do
case find_pattern(zipper, {:defp, _, [{^fun, _, args}, _]} when length(args) == arity) do
nil ->
if arity == 0 do
case find_pattern(zipper, {:defp, _, [{^fun, _, context}, _]} when is_atom(context)) do
nil ->
:error
zipper ->
move_to_do_block(zipper)
end
else
:error
end
zipper ->
move_to_do_block(zipper)
end
end
def move_to_do_block(zipper) do
case find_pattern(zipper, {{:__block__, _, [:do]}, _}) do
nil ->
:error
zipper ->
{:ok,
zipper
|> Zipper.down()
|> Zipper.rightmost()
|> maybe_enter_block()}
end
end
def maybe_enter_block(nil), do: nil
def maybe_enter_block(zipper) do
zipper
|> Zipper.subtree()
|> Zipper.root()
|> case do
{:__block__, _, [_]} ->
Zipper.down(zipper)
_ ->
zipper
end
end
def remove_module_definitions(zipper) do
Sourceror.Zipper.traverse(zipper, fn
{:defmodule, _, _} ->
nil
other ->
other
end)
end
def prepend_new_to_list(zipper, quoted, equality_pred \\ &default_equality_pred/2) do
zipper
|> find_list_item_index(fn value ->
equality_pred.(value, quoted)
end)
|> case do
nil ->
zipper
|> maybe_enter_block()
|> Zipper.insert_child(quoted)
_ ->
zipper
end
end
defp default_equality_pred(zipper, quoted) do
zipper
|> Zipper.subtree()
|> Zipper.root()
|> Kernel.==(quoted)
end
def prepend_to_list(zipper, quoted) do
zipper
|> maybe_enter_block()
|> Zipper.insert_child(quoted)
end
def remove_index(zipper, index) do
zipper
|> maybe_enter_block()
|> Zipper.down()
|> case do
nil ->
zipper
zipper ->
zipper
|> do_remove_index(index)
end
end
defp do_remove_index(zipper, 0) do
Zipper.remove(zipper)
end
defp do_remove_index(zipper, i) do
zipper
|> Zipper.right()
|> case do
nil ->
zipper
zipper ->
zipper
|> do_remove_index(i - 1)
end
end
defp nth_right(zipper, 0) do
zipper
end
defp nth_right(zipper, n) do
zipper
|> Zipper.right()
|> case do
nil ->
nil
zipper ->
nth_right(zipper, n - 1)
end
end
def find_list_item_index(zipper, pred) do
# go into first list item
zipper
|> maybe_enter_block()
|> Zipper.down()
|> case do
nil ->
nil
zipper ->
find_index_right(zipper, pred, 0)
end
end
def find_list_item(zipper, pred) do
# go into first list item
zipper
|> maybe_enter_block()
|> Zipper.down()
|> case do
nil ->
nil
zipper ->
find_right(zipper, pred)
end
end
def is_tuple?(item) do
item
|> Zipper.subtree()
|> Zipper.root()
|> case do
{:{}, _, _} -> true
{_, _} -> true
_ -> false
end
end
def tuple_elem(item, elem) do
item
|> maybe_enter_block()
|> Zipper.down()
|> go_right_n_times(elem)
|> maybe_enter_block()
end
defp go_right_n_times(zipper, 0), do: maybe_enter_block(zipper)
defp go_right_n_times(zipper, n) do
zipper
|> Zipper.right()
|> case do
nil -> nil
zipper -> go_right_n_times(zipper, n - 1)
end
end
defp find_index_right(zipper, pred, index) do
if pred.(maybe_enter_block(zipper)) do
index
else
case Zipper.right(zipper) do
nil ->
nil
zipper ->
zipper
|> find_index_right(pred, index + 1)
end
end
end
defp find_right(zipper, pred) do
if pred.(maybe_enter_block(zipper)) do
zipper
else
case Zipper.right(zipper) do
nil ->
nil
zipper ->
zipper
|> find_right(pred)
end
end
end
@doc false
def keywordify([], value) do
value
end
def keywordify([key | rest], value) do
[{key, keywordify(rest, value)}]
end
end

99
lib/config.ex Normal file
View file

@ -0,0 +1,99 @@
defmodule Igniter.Config do
require Igniter.Common
alias Igniter.Common
alias Sourceror.Zipper
def configure(igniter, file_path, app_name, config_path, value, updater \\ nil) do
file_path = Path.join("config", file_path)
config_path = List.wrap(config_path)
value = Macro.escape(value)
updater = updater || fn zipper -> Zipper.replace(zipper, value) end
igniter
|> Igniter.include_or_create_elixir_file(file_path, "import Config\n")
|> Igniter.update_file(file_path, fn source ->
quoted = Rewrite.Source.get(source, :quoted)
zipper = Zipper.zip(quoted)
case try_update_three_arg(zipper, config_path, app_name, updater) do
{:ok, zipper} ->
Rewrite.Source.update(source, :configure, :quoted, Zipper.root(zipper))
:error ->
case try_update_two_arg(zipper, config_path, app_name, value, updater) do
{:ok, zipper} ->
Rewrite.Source.update(source, :configure, :quoted, Zipper.root(zipper))
:error ->
# add new code here
config =
if Enum.count(config_path) == 1 do
quote do
config unquote(app_name), unquote(Enum.at(config_path, 0)), unquote(value)
end
else
{:config, [], [app_name, Igniter.Common.keywordify(config_path, value)]}
end
code =
zipper
|> Igniter.Common.add_code(config)
|> Zipper.root()
Rewrite.Source.update(
source,
:configure,
:quoted,
code
)
end
end
end)
end
defp try_update_three_arg(zipper, config_path, app_name, updater) do
if Enum.count(config_path) == 1 do
config_item = Enum.at(config_path, 0)
case Common.find_function_call_in_current_scope(zipper, :config, 3, fn function_call ->
Common.argument_matches_pattern?(function_call, 0, ^app_name)
Common.argument_matches_pattern?(function_call, 1, ^config_item)
end) do
nil ->
:error
zipper ->
case Common.update_nth_argument(zipper, 2, updater) do
nil ->
:error
zipper ->
{:ok, zipper}
end
end
else
:error
end
end
defp try_update_two_arg(zipper, config_path, app_name, value, updater) do
case Common.find_function_call_in_current_scope(zipper, :config, 2, fn function_call ->
Common.argument_matches_pattern?(function_call, 0, ^app_name)
end) do
nil ->
:error
zipper ->
Common.update_nth_argument(zipper, 1, fn zipper ->
Igniter.Common.put_in_keyword(zipper, config_path, value, updater)
end)
|> case do
nil ->
nil
zipper ->
{:ok, zipper}
end
end
end
end

133
lib/deps.ex Normal file
View file

@ -0,0 +1,133 @@
defmodule Igniter.Deps do
require Igniter.Common
alias Sourceror.Zipper
alias Igniter.Common
def get_dependency_declaration(igniter, name) do
zipper =
igniter
|> Igniter.include_existing_elixir_file("mix.exs")
|> Map.get(:rewrite)
|> Rewrite.source!("mix.exs")
|> Rewrite.Source.get(:quoted)
|> Zipper.zip()
with {:ok, zipper} <- Common.move_to_module_using(zipper, Mix.Project),
{:ok, zipper} <- Common.move_to_defp(zipper, :deps, 0),
true <- Common.node_matches_pattern?(zipper, value when is_list(value)),
current_declaration when not is_nil(current_declaration) <-
Common.find_list_item(zipper, fn item ->
if Common.is_tuple?(item) do
first_elem = Common.tuple_elem(item, 0)
first_elem && Common.node_matches_pattern?(first_elem, ^name)
end
end) do
current_declaration
|> Zipper.subtree()
|> Zipper.node()
|> Macro.to_string()
else
_ ->
nil
end
end
def add_dependency(igniter, name, version) do
case get_dependency_declaration(igniter, name) do
nil ->
do_add_dependency(igniter, name, version)
current ->
desired = "`{#{inspect(name)}, #{inspect(version)}}`"
current = "`#{current}`"
if desired == current do
igniter
else
if Mix.shell().yes?("""
Dependency #{name} is already in mix.exs. Should we replace it?
Desired: #{desired}
Found: #{current}
""") do
igniter
|> remove_dependency(name)
|> do_add_dependency(name, version)
else
igniter
end
end
end
end
defp remove_dependency(igniter, name) do
igniter
|> Igniter.update_file("mix.exs", fn source ->
quoted = Rewrite.Source.get(source, :quoted)
new_quoted =
with zipper <- Zipper.zip(quoted),
{:ok, zipper} <- Common.move_to_module_using(zipper, Mix.Project),
{:ok, zipper} <- Common.move_to_defp(zipper, :deps, 0),
true <- Common.node_matches_pattern?(zipper, value when is_list(value)),
current_declaration_index when not is_nil(current_declaration_index) <-
Common.find_list_item_index(zipper, fn item ->
if Common.is_tuple?(item) do
first_elem = Common.tuple_elem(item, 0)
first_elem && Common.node_matches_pattern?(first_elem, ^name)
end
end) do
zipper
|> Common.remove_index(current_declaration_index)
|> Zipper.root()
else
_ ->
quoted
end
if new_quoted == quoted do
Rewrite.Source.add_issue(
source,
"Failed to remove dependency #{inspect(name)}"
)
else
Rewrite.Source.update(source, :add_dependency, :quoted, new_quoted)
end
end)
end
defp do_add_dependency(igniter, name, version) do
igniter
|> Igniter.Formatter.import_dep(name)
|> Igniter.update_file("mix.exs", fn source ->
quoted = Rewrite.Source.get(source, :quoted)
new_quoted =
with zipper <- Zipper.zip(quoted),
{:ok, zipper} <- Common.move_to_module_using(zipper, Mix.Project),
{:ok, zipper} <- Common.move_to_defp(zipper, :deps, 0),
true <- Common.node_matches_pattern?(zipper, value when is_list(value)) do
quoted =
quote do
{unquote(name), unquote(version)}
end
zipper
|> Common.prepend_to_list(quoted)
|> Zipper.root()
else
_ ->
quoted
end
if new_quoted == quoted do
Rewrite.Source.add_issue(
source,
"Failed to add dependency #{inspect({inspect(name), inspect(version)})}"
)
else
Rewrite.Source.update(source, :add_dependency, :quoted, new_quoted)
end
end)
end
end

84
lib/formatter.ex Normal file
View file

@ -0,0 +1,84 @@
defmodule Igniter.Formatter do
alias Igniter.Common
alias Sourceror.Zipper
@default_formatter """
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
"""
def import_dep(igniter, dep) do
igniter
|> Igniter.include_or_create_elixir_file(".formatter.exs", @default_formatter)
|> Igniter.update_file(".formatter.exs", fn source ->
quoted = Rewrite.Source.get(source, :quoted)
zipper = Zipper.zip(quoted)
new_code =
zipper
|> Zipper.down()
|> case do
nil ->
code =
quote do
[import_deps: [unquote(dep)]]
end
zipper
|> Igniter.Common.add_code(code)
zipper ->
zipper
|> Zipper.rightmost()
|> Common.put_in_keyword([:import_deps], [dep], fn nested_zipper ->
Igniter.Common.prepend_new_to_list(
nested_zipper,
dep
)
end)
end
|> Zipper.root()
Rewrite.Source.update(source, :import_formatter_dep, :quoted, new_code)
end)
end
def add_formatter_plugin(igniter, plugin) do
igniter
|> Igniter.include_or_create_elixir_file(".formatter.exs", @default_formatter)
|> Igniter.update_file(".formatter.exs", fn source ->
quoted = Rewrite.Source.get(source, :quoted)
zipper = Zipper.zip(quoted)
new_code =
zipper
|> Zipper.down()
|> case do
nil ->
code =
quote do
[plugins: [unquote(plugin)]]
end
zipper
|> Igniter.Common.add_code(code)
zipper ->
zipper
|> Zipper.rightmost()
|> Common.put_in_keyword([:plugins], [Spark.Formatter], fn nested_zipper ->
Igniter.Common.prepend_new_to_list(
nested_zipper,
Spark.Formatter,
&Igniter.Common.equal_modules?/2
)
end)
end
|> Zipper.root()
Rewrite.Source.update(source, :add_formatter_plugin, :quoted, new_code)
end)
end
end

99
lib/igniter.ex Normal file
View file

@ -0,0 +1,99 @@
defmodule Igniter do
@moduledoc """
Igniter is a library for installing packages and generating code.
"""
defstruct [:rewrite, issues: []]
def new() do
%__MODULE__{rewrite: Rewrite.new()}
end
def add_issue(igniter, issue) do
%{igniter | issues: [issue | igniter.issues]}
end
def compose_task(igniter, task_name, argv) do
if igniter.issues == [] do
task_name
|> Mix.Task.get()
|> case do
nil ->
igniter
task ->
Code.ensure_compiled!(task)
if function_exported?(task, :igniter, 2) do
if !task.supports_umbrella?() && Mix.Project.umbrella?() do
raise """
Cannot run #{inspect(task)} in an umbrella project.
"""
end
task.igniter(igniter, argv)
else
add_issue(igniter, "#{inspect(task)} does not implement `Igniter.igniter/2`")
end
end
else
igniter
end
end
def update_file(igniter, path, func) do
if Rewrite.has_source?(igniter.rewrite, path) do
%{igniter | rewrite: Rewrite.update!(igniter.rewrite, path, func)}
else
igniter
|> include_existing_elixir_file(path)
|> update_file(path, func)
end
end
def include_existing_elixir_file(igniter, path) do
if Rewrite.has_source?(igniter.rewrite, path) do
igniter
else
if File.exists?(path) do
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, Rewrite.Source.Ex.read!(path))}
else
add_issue(igniter, "Required #{path} but it did not exist")
end
end
end
def include_or_create_elixir_file(igniter, path, contents \\ "") do
if Rewrite.has_source?(igniter.rewrite, path) do
igniter
else
source =
try do
Rewrite.Source.Ex.read!(path)
rescue
_ ->
""
|> Rewrite.Source.Ex.from_string(path)
|> Rewrite.Source.update(:file_creator, :content, contents)
end
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
end
end
def create_new_elixir_file(igniter, path, contents \\ "") do
source =
try do
path
|> Rewrite.Source.Ex.read!()
|> Rewrite.Source.add_issue("File already exists")
rescue
_ ->
""
|> Rewrite.Source.Ex.from_string(path)
|> Rewrite.Source.update(:file_creator, :content, contents)
end
%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
end
end

29
lib/mix/task.ex Normal file
View file

@ -0,0 +1,29 @@
defmodule Igniter.Mix.Task do
@callback supports_umbrella?() :: boolean()
@callback igniter(igniter :: Igniter.t(), argv :: list(String.t())) :: Igniter.t()
defmacro __using__(_opts) do
quote do
use Mix.Task
@behaviour Igniter.Mix.Task
def run(argv) do
if !supports_umbrella?() && Mix.Project.umbrella?() do
raise """
Cannot run #{inspect(__MODULE__)} in an umbrella project.
"""
end
Application.ensure_all_started([:rewrite])
Igniter.new()
|> igniter(argv)
|> Igniter.Tasks.do_or_dry_run(argv)
end
def supports_umbrella?, do: false
defoverridable supports_umbrella?: 0
end
end
end

View file

@ -0,0 +1,18 @@
defmodule Mix.Tasks.Igniter.Install do
use Mix.Task
@impl true
def run([install | argv]) do
Application.ensure_all_started([:rewrite])
if String.contains?(install, "/") do
raise "installation from github not supported yet"
else
Mix.Task.run("igniter.install_from_hex", [install | argv])
end
end
def run([]) do
raise "must provide a package to install!"
end
end

View file

@ -0,0 +1,9 @@
defmodule Mix.Tasks.Igniter.Install.Spark do
use Igniter.Mix.Task
def igniter(igniter, _argv) do
igniter
|> Igniter.Formatter.add_formatter_plugin(Spark.Formatter)
|> Igniter.Config.configure("config.exs", :spark, [:formatter, :remove_parens?], true, & &1)
end
end

View file

@ -0,0 +1,98 @@
defmodule Mix.Tasks.Igniter.InstallFromHex do
use Mix.Task
@impl true
def run([install | argv]) do
install = String.to_atom(install)
Application.ensure_all_started(:req)
case Req.get!("https://hex.pm/api/packages/#{install}").body do
%{
"releases" => [
%{"version" => version}
| _
]
} ->
requirement =
version
|> Version.parse!()
|> case do
%Version{major: 0, minor: minor} ->
"~> 0.#{minor}"
%Version{major: major} ->
"~> #{major}.0"
end
dependency_add_result =
Igniter.new()
|> Igniter.Deps.add_dependency(install, requirement)
|> Igniter.Tasks.do_or_dry_run(argv,
title: "Fetching Dependency",
quiet_on_no_changes?: true
)
if dependency_add_result == :issues do
raise "Exiting due to issues found while fetching dependency"
end
if dependency_add_result == :dry_run_with_changes do
install_dep_now? =
Mix.shell().yes?("""
Cannot display any further installation changes without installing the `#{install}` dependency.
Would you like to install the dependency now?
This will be the only change made, and then any remaining steps will be displayed as a dry-run.
""")
if install_dep_now? do
Igniter.new()
|> Igniter.Deps.add_dependency(install, requirement)
|> Igniter.Tasks.do_or_dry_run(argv -- ["--dry-run"],
title: "Fetching Dependency",
quiet_on_no_changes?: true
)
end
end
case System.cmd("mix", ["deps.get"]) do
{_, 0} ->
:ok
{output, exit} ->
Mix.shell().info("""
mix deps.get returned exited with code: `#{exit}`
#{output}
""")
end
Mix.Task.load_all()
|> Enum.find(fn module ->
Mix.Task.task_name(module) == "igniter.install.#{install}"
end)
|> case do
nil ->
if dependency_add_result in [:dry_run_with_no_changes, :no_changes] do
Mix.shell().info("Igniter: #{install} already installed")
else
if dependency_add_result == :changes_aborted do
Mix.shell().info("Igniter: #{install} installation aborted")
else
Mix.shell().info("Igniter: #{install} installation complete")
end
end
_task ->
Mix.shell().info("Igniter: Installing #{install}...")
Mix.Task.run("igniter.install.#{install}", argv)
end
_ ->
raise "No published versions of #{install}"
end
:ok
end
end

12
lib/module.ex Normal file
View file

@ -0,0 +1,12 @@
defmodule Igniter.Module do
def module_name(suffix) do
Module.concat(module_name_prefix(), suffix)
end
def module_name_prefix() do
Mix.Project.get!()
|> Module.split()
|> :lists.droplast()
|> Module.concat()
end
end

117
lib/tasks.ex Normal file
View file

@ -0,0 +1,117 @@
defmodule Igniter.Tasks do
def app_name do
Mix.Project.config()[:app]
end
def do_or_dry_run(igniter, argv, opts \\ []) do
title = opts[:title] || "Igniter"
sources =
igniter.rewrite
|> Rewrite.sources()
issues =
Enum.flat_map(sources, fn source ->
changed_issues =
if Rewrite.Source.file_changed?(source) do
["File has been changed since it was originally read."]
else
[]
end
issues = changed_issues ++ Rewrite.Source.issues(source)
case issues do
[] -> []
issues -> [{source, issues}]
end
end)
case issues do
[_ | _] ->
explain_issues(issues)
:issues
[] ->
if igniter.issues == [] do
result_of_dry_run =
sources
|> Enum.filter(fn source ->
Rewrite.Source.updated?(source)
end)
|> case do
[] ->
unless opts[:quiet_on_no_changes?] do
IO.puts("\n#{title}: No proposed changes!\n")
end
:dry_run_with_no_changes
sources ->
IO.puts("\n#{title}: Proposed changes:\n")
Enum.each(sources, fn source ->
IO.puts("""
#{Rewrite.Source.get(source, :path)}
#{Rewrite.Source.diff(source)}
""")
end)
:dry_run_with_changes
end
if "--dry-run" in argv || result_of_dry_run == :dry_run_with_no_changes do
result_of_dry_run
else
if "--yes" in argv || Mix.shell().yes?("Proceed with changes?") do
sources
|> Enum.any?(fn source ->
Rewrite.Source.updated?(source)
end)
|> if do
igniter.rewrite
|> Rewrite.write_all()
:changes_made
else
:no_changes
end
else
:changes_aborted
end
end
else
IO.puts("Issues during code generation")
igniter.issues
|> Enum.map_join("\n", fn error ->
if is_binary(error) do
"* #{error}"
else
"* #{Exception.format(:error, error)}"
end
end)
|> IO.puts()
end
end
end
defp explain_issues(issues) do
IO.puts("Igniter: Issues found in proposed changes:\n")
Enum.each(issues, fn {source, issues} ->
IO.puts("Issues with #{Rewrite.Source.get(source, :path)}")
issues
|> Enum.map_join("\n", fn error ->
if is_binary(error) do
"* #{error}"
else
"* #{Exception.format(:error, error)}"
end
end)
|> IO.puts()
end)
end
end

29
mix.exs Normal file
View file

@ -0,0 +1,29 @@
defmodule Igniter.MixProject do
use Mix.Project
def project do
[
app: :igniter,
version: "0.1.0",
elixir: "~> 1.16",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:spark, "~> 2.0"},
{:rewrite, "~> 0.9"},
{:req, "~> 0.4"}
]
end
end

27
mix.lock Normal file
View file

@ -0,0 +1,27 @@
%{
"ash": {:hex, :ash, "3.0.7", "6c37e092f53b1b21eb89596f600a652b2a601f84378f44fd5dd1cdec72eb1cc2", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, ">= 0.8.1 and < 1.0.0-0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.1.18 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9288ddb50fe727096c6f63fd82c631de2505dcd29bdfa50b5dc13c865f0bf434"},
"castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"},
"comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"},
"glob_ex": {:hex, :glob_ex, "0.1.7", "eae6b6377147fb712ac45b360e6dbba00346689a87f996672fe07e97d70597b1", [:mix], [], "hexpm", "decc1c21c0c73df3c9c994412716345c1692477b9470e337f628a7e08da0da6a"},
"hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
"mint": {:hex, :mint, "1.6.0", "88a4f91cd690508a04ff1c3e28952f322528934be541844d54e0ceb765f01d5e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "3c5ae85d90a5aca0a49c0d8b67360bbe407f3b54f1030a111047ff988e8fefaa"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_ownership": {:hex, :nimble_ownership, "0.3.1", "99d5244672fafdfac89bfad3d3ab8f0d367603ce1dc4855f86a1c75008bce56f", [:mix], [], "hexpm", "4bf510adedff0449a1d6e200e43e57a814794c8b5b6439071274d248d272a549"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"reactor": {:hex, :reactor, "0.8.4", "344d02ba4a0010763851f4e4aa0ff190ebe7e392e3c27c6cd143dde077b986e7", [:mix], [{:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "49c1fd3c786603cec8140ce941c41c7ea72cc4411860ccdee9876c4ca2204f81"},
"req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"},
"rewrite": {:hex, :rewrite, "0.10.1", "238073297d122dad6b5501d761cb3bc0ce5bb4ab86e34c826c395f5f44b2f562", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "91f8d6fe363033e8ff60097bb5e0b76867667df0b4d67e79c2850444c02d8b19"},
"sourceror": {:hex, :sourceror, "1.2.1", "b415255ad8bd05f0e859bb3d7ea617f6c2a4a405f2a534a231f229bd99b89f8b", [:mix], [], "hexpm", "e4d97087e67584a7585b5fe3d5a71bf8e7332f795dd1a44983d750003d5e750c"},
"spark": {:hex, :spark, "2.1.22", "a36400eede64c51af578de5fdb5a5aaa3e0811da44bcbe7545fce059bd2a990b", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f764611d0b15ac132e72b2326539acc11fc4e63baa3e429f541bca292b5f7064"},
"splode": {:hex, :splode, "0.2.4", "71046334c39605095ca4bed5d008372e56454060997da14f9868534c17b84b53", [:mix], [], "hexpm", "ca3b95f0d8d4b482b5357954fec857abd0fa3ea509d623334c1328e7382044c2"},
"stream_data": {:hex, :stream_data, "1.0.0", "c1380747a4650902732696861d5cb66ad3cb1cc93f31c2c8498bf87cddbabe2d", [:mix], [], "hexpm", "acd53e27c66c617d466f42ec77a7f59e5751f6051583c621ccdb055b9690435d"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},
}

8
test/igniter_test.exs Normal file
View file

@ -0,0 +1,8 @@
defmodule IgniterTest do
use ExUnit.Case
doctest Igniter
test "greets the world" do
assert Igniter.hello() == :world
end
end

1
test/test_helper.exs Normal file
View file

@ -0,0 +1 @@
ExUnit.start()