From 29e492002a5fa81786645f00309807c073092bcf Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Fri, 26 Jul 2024 13:30:41 -0400 Subject: [PATCH] improvement: add `positional_args!/1` macro for use in tasks fix: recompile igniter in `ingiter.install` --- installer/lib/private/deps_compile.ex | 12 ++- lib/igniter/project/deps.ex | 4 +- lib/igniter/util/info.ex | 2 +- lib/igniter/util/install.ex | 2 +- lib/mix/task.ex | 105 +++++++++++++++++++++++- test/mix/tasks/igniter.install_test.exs | 6 +- 6 files changed, 120 insertions(+), 11 deletions(-) diff --git a/installer/lib/private/deps_compile.ex b/installer/lib/private/deps_compile.ex index 318ffd1..da871f4 100644 --- a/installer/lib/private/deps_compile.ex +++ b/installer/lib/private/deps_compile.ex @@ -37,11 +37,13 @@ defmodule Igniter.Util.DepsCompile do # * `--skip-umbrella-children` - skips umbrella applications 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!() 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) end @@ -53,7 +55,11 @@ defmodule Igniter.Util.DepsCompile do Mix.Task.run("deps.precompile") igniter_needs_compiling? = - not Code.ensure_loaded?(Igniter) + if options[:recompile_igniter?] do + true + else + not Code.ensure_loaded?(Igniter) + end compiled = deps diff --git a/lib/igniter/project/deps.ex b/lib/igniter/project/deps.ex index ab252e9..3f85884 100644 --- a/lib/igniter/project/deps.ex +++ b/lib/igniter/project/deps.ex @@ -53,8 +53,8 @@ defmodule Igniter.Project.Deps do Mix.shell().yes?(""" Dependency #{name} is already in mix.exs. Should we replace it? - Desired: `#{inspect desired}` - Found: `#{inspect current}` + Desired: `#{inspect(desired)}` + Found: `#{inspect(current)}` """) do igniter |> remove_dependency(name) diff --git a/lib/igniter/util/info.ex b/lib/igniter/util/info.ex index c83de82..16202f4 100644 --- a/lib/igniter/util/info.ex +++ b/lib/igniter/util/info.ex @@ -27,7 +27,7 @@ defmodule Igniter.Util.Info do {igniter, Enum.map(Enum.uniq(acc), &"#{&1}.install"), validate!(argv, schema, task_name)} installs -> - schema = %{schema | installs: []} + schema = %{schema | installs: []} install_names = Keyword.keys(installs) igniter diff --git a/lib/igniter/util/install.ex b/lib/igniter/util/install.ex index 287e479..d9ee30c 100644 --- a/lib/igniter/util/install.ex +++ b/lib/igniter/util/install.ex @@ -221,7 +221,7 @@ defmodule Igniter.Util.Install do |> File.read!() |> Code.eval_string([], file: Path.expand("mix.exs")) - Igniter.Util.DepsCompile.run() + Igniter.Util.DepsCompile.run(recompile_igniter?: true) exit_code -> Mix.shell().info(""" diff --git a/lib/mix/task.ex b/lib/mix/task.ex index e1f3c48..1dca394 100644 --- a/lib/mix/task.ex +++ b/lib/mix/task.ex @@ -16,13 +16,22 @@ defmodule Igniter.Mix.Task do ## Configurable Keys * `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. * `composes` - A list of tasks that this task might compose. * `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. * `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! + + ## Positonal Arguments + + Each positional argument can provide the following options: + + * `:optional` - Whether or not the argument is optional. Defaults to `false`. """ @global_options [ @@ -40,14 +49,18 @@ defmodule Igniter.Mix.Task do composes: [], installs: [], adds_deps: [], + positional: [], + example: nil, extra_args?: false @type t :: %__MODULE__{ schema: Keyword.t(), aliases: Keyword.t(), composes: [String.t()], + positional: list(atom | {atom, [{:optional, boolean()}, {:example, String.t()}]}), installs: [{atom(), String.t()}], adds_deps: [{atom(), String.t()}], + example: String.t() | nil, extra_args?: boolean() } @@ -59,6 +72,9 @@ defmodule Igniter.Mix.Task do 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 * 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 use 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 def run(argv) do @@ -122,14 +138,97 @@ defmodule Igniter.Mix.Task do end end + @doc "Parses the options for the task based on its info." @spec options!(argv :: term()) :: term() | no_return defmacro options!(argv) do quote do 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 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 diff --git a/test/mix/tasks/igniter.install_test.exs b/test/mix/tasks/igniter.install_test.exs index eb60abd..71eb6a8 100644 --- a/test/mix/tasks/igniter.install_test.exs +++ b/test/mix/tasks/igniter.install_test.exs @@ -30,7 +30,11 @@ defmodule Igniter.Mix.Tasks.InstallTest do test "rerunning the same installer lets you know the dependency was not changed" do _ = 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