improvement: clean up dependency compiling logic

fix: ensure igniter is compiled first
fix: fetch deps after adding any nested installers
improvement: optimize module finding w/ async_stream
fix: various fixes & improvements to positional argument listing
improvement: add `rest: true` option for positional args
This commit is contained in:
Zach Daniel 2024-07-27 16:57:10 -04:00
parent 279261fa31
commit c52b226a1c
5 changed files with 156 additions and 42 deletions

View file

@ -42,7 +42,7 @@ defmodule Igniter.Util.DepsCompile do
deps = Mix.Dep.load_and_cache()
opts =
[include_children: true, force: true]
[include_children: true]
|> Keyword.put(:recompile_igniter?, Keyword.get(opts, :recompile_igniter?))
compile(filter_available_and_local_deps(deps), opts)
@ -54,12 +54,7 @@ defmodule Igniter.Util.DepsCompile do
config = Mix.Project.deps_config()
Mix.Task.run("deps.precompile")
igniter_needs_compiling? =
if options[:recompile_igniter?] do
true
else
not Code.ensure_loaded?(Igniter)
end
igniter_needs_compiling? = not Code.ensure_loaded?(Igniter)
compiled =
deps
@ -70,6 +65,9 @@ defmodule Igniter.Util.DepsCompile do
Enum.reject(deps, &(&1.app == :igniter))
end
end)
|> Enum.sort_by(fn %{app: app} ->
app != :igniter
end)
|> Enum.map(fn %Mix.Dep{app: app, status: status, opts: opts, scm: scm} = dep ->
check_unavailable!(app, scm, status)
maybe_clean(dep, options)

View file

@ -125,18 +125,18 @@ defmodule Igniter.Code.Module do
igniter
|> Map.get(:rewrite)
|> Enum.find_value({:error, igniter}, fn source ->
source
|> Rewrite.Source.get(:quoted)
|> Zipper.zip()
|> move_to_defmodule(module_name)
|> case do
{:ok, zipper} ->
{:ok, {igniter, source, zipper}}
|> Task.async_stream(fn source ->
{source
|> Rewrite.Source.get(:quoted)
|> Zipper.zip()
|> move_to_defmodule(module_name), source}
end)
|> Enum.find_value({:error, igniter}, fn
{:ok, {{:ok, zipper}, source}} ->
{:ok, {igniter, source, zipper}}
_ ->
nil
end
_other ->
false
end)
end
@ -150,7 +150,7 @@ defmodule Igniter.Code.Module do
matching_modules =
igniter
|> Map.get(:rewrite)
|> Enum.flat_map(fn source ->
|> Task.async_stream(fn source ->
source
|> Rewrite.Source.get(:quoted)
|> Zipper.zip()
@ -180,6 +180,9 @@ defmodule Igniter.Code.Module do
end)
|> elem(1)
end)
|> Enum.flat_map(fn {:ok, v} ->
v
end)
{igniter, matching_modules}
end

View file

@ -19,7 +19,7 @@ defmodule Igniter.Util.Info do
igniter =
igniter
|> add_deps(
List.wrap(schema.adds_deps) ++ List.wrap(schema.installs),
List.wrap(schema.adds_deps),
opts
)
|> Igniter.apply_and_fetch_dependencies(opts)
@ -31,6 +31,10 @@ defmodule Igniter.Util.Info do
install_names = Keyword.keys(installs)
igniter
|> add_deps(
List.wrap(installs),
opts
)
|> Igniter.apply_and_fetch_dependencies(opts)
|> compose_install_and_validate!(
argv,
@ -38,7 +42,7 @@ defmodule Igniter.Util.Info do
schema
| composes: Enum.map(install_names, &"#{&1}.install"),
installs: [],
adds_deps: schema.adds_deps ++ installs
adds_deps: schema.adds_deps
},
task_name,
opts,

View file

@ -32,6 +32,9 @@ defmodule Igniter.Mix.Task do
Each positional argument can provide the following options:
* `:optional` - Whether or not the argument is optional. Defaults to `false`.
* `:rest` - Whether or not the argument consumes the rest of the positional arguments. Defaults to `false`.
The value will be converted to a list automatically.
"""
@global_options [
@ -57,7 +60,7 @@ defmodule Igniter.Mix.Task do
schema: Keyword.t(),
aliases: Keyword.t(),
composes: [String.t()],
positional: list(atom | {atom, [{:optional, boolean()}, {:example, String.t()}]}),
positional: list(atom | {atom, [{:optional, boolean()}, {:rest, boolean()}]}),
installs: [{atom(), String.t()}],
adds_deps: [{atom(), String.t()}],
example: String.t() | nil,
@ -158,6 +161,8 @@ defmodule Igniter.Mix.Task do
task_name = Mix.Task.task_name(__MODULE__)
info = info(argv, task_name)
{argv, positional} = Igniter.Mix.Task.extract_positional_args(argv)
desired =
Enum.map(info.positional, fn
value when is_atom(value) ->
@ -167,26 +172,20 @@ defmodule Igniter.Mix.Task do
other
end)
{rest, positional} = Enum.split_with(argv, &String.starts_with?(&1, "-"))
{remaining_desired, got} =
Enum.reduce(positional, {desired, []}, fn
_arg, {[], got} ->
{[], got}
arg, {desired, got} ->
{name, _config} =
Enum.find(desired, fn {_name, config} ->
!config[:optional]
end) || Enum.at(desired, 0)
{Keyword.delete(desired, name), Keyword.put(got, name, arg)}
end)
Igniter.Mix.Task.consume_args(positional, desired)
case Enum.find(remaining_desired, fn {_arg, config} -> !config[:optional] end) do
{name, _config} ->
{name, config} ->
line =
if config[:rest] do
"Must provide one or more values for positional argument `#{name}`"
else
"Required positional argument `#{name}` was not supplied."
end
raise ArgumentError, """
Required positional argument `#{name}` was not supplied.
#{line}
Command: `#{Igniter.Mix.Task.call_structure(task_name, desired)}`
#{Igniter.Mix.Task.call_example(info)}
@ -195,11 +194,48 @@ defmodule Igniter.Mix.Task do
"""
_ ->
{Map.new(got), rest}
{Igniter.Mix.Task.add_default_values(Map.new(got), desired), positional}
end
end
end
@doc false
def add_default_values(got, desired) do
Enum.reduce(desired, got, fn {name, config}, acc ->
if config[:optional] do
if config[:rest] do
Map.update(got, name, [], &List.wrap/1)
else
Map.put_new(got, name, nil)
end
else
acc
end
end)
end
@doc false
def consume_args(positional, desired, got \\ [])
def consume_args([], desired, got) do
{desired, got}
end
def consume_args([arg | positional], desired, got) do
{name, config} =
Enum.find(desired, fn {_name, config} ->
!config[:optional]
end) || Enum.at(desired, 0)
desired = Keyword.delete(desired, name)
if config[:rest] do
{desired, Keyword.put(got, name, [arg | positional])}
else
consume_args(positional, desired, Keyword.put(got, name, arg))
end
end
@doc false
def call_example(info) do
if info.example do
@ -212,6 +248,27 @@ defmodule Igniter.Mix.Task do
end
end
@doc false
def extract_positional_args(argv, argv \\ [], positional \\ [])
def extract_positional_args([], argv, positional), do: {argv, positional}
def extract_positional_args(argv, got_argv, positional) do
case OptionParser.next(argv, switches: []) do
{:ok, key, value, rest} ->
extract_positional_args(rest, got_argv ++ [{key, value}], positional)
{:invalid, key, value, rest} ->
extract_positional_args(rest, got_argv ++ [{key, value}], positional)
{:undefined, key, value, rest} ->
extract_positional_args(rest, got_argv ++ [{key, value}], positional)
{:error, rest} ->
[first | rest] = rest
extract_positional_args(rest, got_argv, positional ++ [first])
end
end
defp indent(example) do
example
|> String.split("\n")
@ -222,10 +279,17 @@ defmodule Igniter.Mix.Task do
def call_structure(name, desired) do
call =
Enum.map_join(desired, " ", fn {name, config} ->
if config[:optional] do
"[#{name}]"
with_optional =
if config[:optional] do
"[#{name}]"
else
to_string(name)
end
if config[:rest] do
with_optional <> "[...]"
else
name
with_optional
end
end)

45
test/mix/task_test.exs Normal file
View file

@ -0,0 +1,45 @@
defmodule Igniter.Mix.TaskTest do
use ExUnit.Case
defmodule ExampleTask do
use Igniter.Mix.Task
def info(_argv, _parent) do
%Igniter.Mix.Task.Info{
schema: [
option: :string
],
positional: [
:a,
b: [
optional: true,
rest: true
]
]
}
end
def igniter(igniter, argv) do
options = options!(argv)
{args, _argv} = positional_args!(argv)
send(self(), {:args, args})
send(self(), {:options, options})
igniter
end
end
test "it parses options" do
ExampleTask.igniter(Igniter.new(), ["foo", "--option", "foo"])
assert_received {:options, options}
assert options[:option] == "foo"
assert_received {:args, %{a: "foo"}}
end
test "it parses rest options" do
ExampleTask.igniter(Igniter.new(), ["foo", "--option", "foo"])
assert_received {:options, options}
assert options[:option] == "foo"
assert_received {:args, %{a: "foo"}}
end
end