improvement: add positional_args!/1 macro for use in tasks

fix: recompile igniter in `ingiter.install`
This commit is contained in:
Zach Daniel 2024-07-26 13:30:41 -04:00
parent ef79a5c3ba
commit 29e492002a
6 changed files with 120 additions and 11 deletions

View file

@ -37,11 +37,13 @@ defmodule Igniter.Util.DepsCompile do
# * `--skip-umbrella-children` - skips umbrella applications from compiling # * `--skip-umbrella-children` - skips umbrella applications from compiling
# * `--skip-local-deps` - skips non-remote dependencies, such as path deps, from compiling # * `--skip-local-deps` - skips non-remote dependencies, such as path deps, from compiling
def run do def run(opts \\ []) do
Mix.Project.get!() Mix.Project.get!()
deps = Mix.Dep.load_and_cache() deps = Mix.Dep.load_and_cache()
opts = [include_children: true, force: true] opts =
[include_children: true, force: true]
|> Keyword.put(:recompile_igniter?, Keyword.get(opts, :recompile_igniter?))
compile(filter_available_and_local_deps(deps), opts) compile(filter_available_and_local_deps(deps), opts)
end end
@ -53,7 +55,11 @@ defmodule Igniter.Util.DepsCompile do
Mix.Task.run("deps.precompile") Mix.Task.run("deps.precompile")
igniter_needs_compiling? = igniter_needs_compiling? =
not Code.ensure_loaded?(Igniter) if options[:recompile_igniter?] do
true
else
not Code.ensure_loaded?(Igniter)
end
compiled = compiled =
deps deps

View file

@ -53,8 +53,8 @@ defmodule Igniter.Project.Deps do
Mix.shell().yes?(""" Mix.shell().yes?("""
Dependency #{name} is already in mix.exs. Should we replace it? Dependency #{name} is already in mix.exs. Should we replace it?
Desired: `#{inspect desired}` Desired: `#{inspect(desired)}`
Found: `#{inspect current}` Found: `#{inspect(current)}`
""") do """) do
igniter igniter
|> remove_dependency(name) |> remove_dependency(name)

View file

@ -27,7 +27,7 @@ defmodule Igniter.Util.Info do
{igniter, Enum.map(Enum.uniq(acc), &"#{&1}.install"), validate!(argv, schema, task_name)} {igniter, Enum.map(Enum.uniq(acc), &"#{&1}.install"), validate!(argv, schema, task_name)}
installs -> installs ->
schema = %{schema | installs: []} schema = %{schema | installs: []}
install_names = Keyword.keys(installs) install_names = Keyword.keys(installs)
igniter igniter

View file

@ -221,7 +221,7 @@ defmodule Igniter.Util.Install do
|> File.read!() |> File.read!()
|> Code.eval_string([], file: Path.expand("mix.exs")) |> Code.eval_string([], file: Path.expand("mix.exs"))
Igniter.Util.DepsCompile.run() Igniter.Util.DepsCompile.run(recompile_igniter?: true)
exit_code -> exit_code ->
Mix.shell().info(""" Mix.shell().info("""

View file

@ -16,13 +16,22 @@ defmodule Igniter.Mix.Task do
## Configurable Keys ## Configurable Keys
* `schema` - The option schema for this task, in the format given to `OptionParser`, i.e `[name: :string]` * `schema` - The option schema for this task, in the format given to `OptionParser`, i.e `[name: :string]`
* `positional` - A list of positional arguments that this task accepts. A list of atoms, or a keyword list with the option and config.
See the positional arguments section for more.
* `aliases` - A map of aliases to the schema keys. * `aliases` - A map of aliases to the schema keys.
* `composes` - A list of tasks that this task might compose. * `composes` - A list of tasks that this task might compose.
* `installs` - A list of dependencies that should be installed before continuing. * `installs` - A list of dependencies that should be installed before continuing.
* `adds_deps` - A list of dependencies that should be added to the `mix.exs`, but do not need to be installed before continuing. * `adds_deps` - A list of dependencies that should be added to the `mix.exs`, but do not need to be installed before continuing.
* `extra_args?` - Whether or not to allow extra arguments. This forces all tasks that compose this task to allow extra args as well. * `extra_args?` - Whether or not to allow extra arguments. This forces all tasks that compose this task to allow extra args as well.
* `example` - An example usage of the task. This is used in the help output.
Your task should *always* use `switches` and not `strict` to validate provided options! Your task should *always* use `switches` and not `strict` to validate provided options!
## Positonal Arguments
Each positional argument can provide the following options:
* `:optional` - Whether or not the argument is optional. Defaults to `false`.
""" """
@global_options [ @global_options [
@ -40,14 +49,18 @@ defmodule Igniter.Mix.Task do
composes: [], composes: [],
installs: [], installs: [],
adds_deps: [], adds_deps: [],
positional: [],
example: nil,
extra_args?: false extra_args?: false
@type t :: %__MODULE__{ @type t :: %__MODULE__{
schema: Keyword.t(), schema: Keyword.t(),
aliases: Keyword.t(), aliases: Keyword.t(),
composes: [String.t()], composes: [String.t()],
positional: list(atom | {atom, [{:optional, boolean()}, {:example, String.t()}]}),
installs: [{atom(), String.t()}], installs: [{atom(), String.t()}],
adds_deps: [{atom(), String.t()}], adds_deps: [{atom(), String.t()}],
example: String.t() | nil,
extra_args?: boolean() extra_args?: boolean()
} }
@ -59,6 +72,9 @@ defmodule Igniter.Mix.Task do
This info will be used to validate arguments in composed tasks. This info will be used to validate arguments in composed tasks.
Use the `positional_args!(argv)` to get your positional arguments according to your `info.positional`, and the remaining unused args.
Use the `options!(argv)` macro to get your parsed options according to your `info.schema`.
## Important Limitations ## Important Limitations
* Each task still must parse its own argv in `igniter/2` and *must* ignore any unknown options. * Each task still must parse its own argv in `igniter/2` and *must* ignore any unknown options.
@ -74,7 +90,7 @@ defmodule Igniter.Mix.Task do
quote do quote do
use Mix.Task use Mix.Task
@behaviour Igniter.Mix.Task @behaviour Igniter.Mix.Task
import Igniter.Mix.Task, only: [options!: 1] import Igniter.Mix.Task, only: [options!: 1, positional_args!: 1]
@impl Mix.Task @impl Mix.Task
def run(argv) do def run(argv) do
@ -122,14 +138,97 @@ defmodule Igniter.Mix.Task do
end end
end end
@doc "Parses the options for the task based on its info."
@spec options!(argv :: term()) :: term() | no_return @spec options!(argv :: term()) :: term() | no_return
defmacro options!(argv) do defmacro options!(argv) do
quote do quote do
argv = unquote(argv) argv = unquote(argv)
info = info(argv, Mix.Task.task_name(__MODULE__))
{parsed, _} = OptionParser.parse!(argv, switches: info.schema, aliases: info.aliases)
task_name = Mix.Task.task_name(__MODULE__)
info = info(argv, task_name)
{parsed, _} = OptionParser.parse!(argv, switches: info.schema, aliases: info.aliases)
parsed parsed
end end
end end
defmacro positional_args!(argv) do
quote do
argv = unquote(argv)
task_name = Mix.Task.task_name(__MODULE__)
info = info(argv, task_name)
desired =
Enum.map(info.positional, fn
value when is_atom(value) ->
{value, []}
other ->
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)
case Enum.find(remaining_desired, fn {_arg, config} -> !config[:optional] end) do
{name, _config} ->
raise ArgumentError, """
Required positional argument `#{name}` was not supplied.
Command: `#{Igniter.Mix.Task.call_structure(task_name, desired)}`
#{Igniter.Mix.Task.call_example(info)}
Run `mix help #{task_name}` for more information.
"""
_ ->
{Map.new(got), rest}
end
end
end
@doc false
def call_example(info) do
if info.example do
"""
Example:
#{indent(info.example)}
"""
end
end
defp indent(example) do
example
|> String.split("\n")
|> Enum.map_join("\n", &" #{&1}")
end
@doc false
def call_structure(name, desired) do
call =
Enum.map_join(desired, " ", fn {name, config} ->
if config[:optional] do
"[#{name}]"
else
name
end
end)
"mix #{name} #{call}"
end
end end

View file

@ -30,7 +30,11 @@ defmodule Igniter.Mix.Tasks.InstallTest do
test "rerunning the same installer lets you know the dependency was not changed" do test "rerunning the same installer lets you know the dependency was not changed" do
_ = cmd!("mix", ["igniter.install", "jason", "--yes"], cd: "test_project") _ = cmd!("mix", ["igniter.install", "jason", "--yes"], cd: "test_project")
output = cmd!("mix", ["igniter.install", "jason", "--yes"], cd: "test_project") output = cmd!("mix", ["igniter.install", "jason", "--yes"], cd: "test_project")
assert String.contains?(output, "Dependency jason is already in mix.exs with the desired version. Skipping.")
assert String.contains?(
output,
"Dependency jason is already in mix.exs with the desired version. Skipping."
)
end end
end end