Skip to content

Commit

Permalink
improvement: add option_schema/2 callback to Igniter.Mix.Task
Browse files Browse the repository at this point in the history
  • Loading branch information
zachdaniel committed Jun 28, 2024
1 parent 6151962 commit fd475c3
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 11 deletions.
50 changes: 49 additions & 1 deletion lib/igniter/mix/task.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
defmodule Igniter.Mix.Task do
@moduledoc "A behaviour for implementing a Mix task that is enriched to be composable with other Igniter tasks."

@doc """
Whether or not it supports being run in the root of an umbrella project
Expand All @@ -8,12 +9,41 @@ defmodule Igniter.Mix.Task do
@callback supports_umbrella?() :: boolean()
@doc "All the generator behavior happens here, you take an igniter and task arguments, and return an igniter."
@callback igniter(igniter :: Igniter.t(), argv :: list(String.t())) :: Igniter.t()
@doc """
Returns an option schema and a list of tasks that this task *might* compose *and* pass all argv to.
The option schema should be in the format you give to `OptionParser`.
This is callback is used to validate all options up front.
The following keys can be returned:
* `schema` - The option schema for this task.
* `aliases` - A map of aliases to the schema keys.
* `extra_args?` - Whether or not to allow extra arguments. This forces all tasks that compose this task to allow extra args as well.
* `composes` - A list of tasks that this task might compose.
Your task should *always* use `switches` and not `strict` to validate provided options!
## Important Limitations
* Each task still must parse its own argv in `igniter/2` and *must* ignore any unknown options.
* You cannot use `composes` to list tasks unless they are in your library or in direct dependencies of your library.
To validate their options, you must include their options in your own option schema.
"""
@callback option_schema(argv :: list(String.t()), source :: nil | String.t()) ::
%{
optional(:schema) => Keyword.t(),
optional(:aliases) => Keyword.t(),
optional(:composes) => [String.t()]
}
| nil

defmacro __using__(_opts) do
quote do
use Mix.Task
@behaviour Igniter.Mix.Task

@impl Mix.Task
def run(argv) do
if !supports_umbrella?() && Mix.Project.umbrella?() do
raise """
Expand All @@ -23,14 +53,32 @@ defmodule Igniter.Mix.Task do

Application.ensure_all_started([:rewrite])

schema = option_schema(argv, nil)
Igniter.Util.Options.validate!(argv, schema, Mix.Task.task_name(__MODULE__))

Igniter.new()
|> igniter(argv)
|> Igniter.do_or_dry_run(argv)
end

@impl Igniter.Mix.Task
def supports_umbrella?, do: false

defoverridable supports_umbrella?: 0
@impl Igniter.Mix.Task
def option_schema(argv, source) do
require Logger

if source && source != "igniter.install" do
Logger.warning("""
The task #{Mix.Task.task_name(__MODULE__)} is being composed by #{source}, but it does not declare an option schema.
Therefore, all options will be allowed. Tasks that may be composed should define `option_schema/2`.
""")
end

nil
end

defoverridable supports_umbrella?: 0, option_schema: 2
end
end
end
33 changes: 23 additions & 10 deletions lib/igniter/util/install.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Igniter.Util.Install do
@moduledoc false
@option_schema [
strict: [
switches: [
example: :boolean,
dry_run: :boolean,
yes: :boolean
Expand All @@ -24,8 +24,8 @@ defmodule Igniter.Util.Install do

Application.ensure_all_started(:req)

{options, _errors, _unprocessed_argv} =
OptionParser.parse(argv, @option_schema)
{options, _unprocessed_argv} =
OptionParser.parse!(argv, @option_schema)

igniter = Igniter.new()

Expand Down Expand Up @@ -124,13 +124,26 @@ defmodule Igniter.Util.Install do

desired_tasks = Enum.map(install_list, &"#{&1}.install")

Mix.Task.load_all()
|> Stream.map(fn item ->
Code.ensure_compiled!(item)
item
end)
|> Stream.filter(&implements_behaviour?(&1, Igniter.Mix.Task))
|> Stream.filter(&(Mix.Task.task_name(&1) in desired_tasks))
igniter_tasks =
Mix.Task.load_all()
|> Stream.map(fn item ->
Code.ensure_compiled!(item)
item
end)
|> Stream.filter(&implements_behaviour?(&1, Igniter.Mix.Task))
|> Enum.filter(&(Mix.Task.task_name(&1) in desired_tasks))

Igniter.Util.Options.validate!(
argv,
%{
schema: @option_schema[:switches],
aliases: @option_schema[:aliases],
composes: desired_tasks
},
"igniter.install"
)

igniter_tasks
|> Enum.reduce(igniter, fn task, igniter ->
Igniter.compose_task(igniter, task, argv)
end)
Expand Down
105 changes: 105 additions & 0 deletions lib/igniter/util/options.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
defmodule Igniter.Util.Options do
@moduledoc false

require Logger

def validate!(argv, schema, task_name)
def validate!(_argv, nil, _task_name), do: :ok

def validate!(argv, schema, task_name) do
merged_schema =
schema
|> Map.put_new(:schema, [])
|> Map.put_new(:composes, [])
|> Map.put_new(:extra_args?, false)
|> recursively_compose_schema(argv, task_name)

options_key =
if merged_schema[:extra_args?] do
:switches
else
:strict
end

OptionParser.parse!(
argv,
[
{options_key, merged_schema[:schema] || []},
{:aliases, merged_schema[:aliases] || []}
]
)
end

defp recursively_compose_schema(%{composes: []} = schema, _argv, _parent), do: schema

defp recursively_compose_schema(%{composes: [compose | rest]} = schema, argv, parent) do
with composing_task when not is_nil(composing_task) <- Mix.Task.get(compose),
true <- function_exported?(composing_task, :option_schema, 2),
composing_schema when not is_nil(schema) <- composing_task.option_schema(argv, parent) do
composing_task_name = Mix.Task.task_name(composing_task)

recursively_compose_schema(
%{
schema
| schema:
merge_schemas(
schema[:schema],
composing_schema[:schema],
parent,
composing_task_name
),
aliases:
merge_aliases(
schema[:aliases],
composing_schema[:aliases],
parent,
composing_task_name
),
composes: List.wrap(composing_schema[:composes]) ++ rest,
extra_args?: schema[:extra_args?] || composing_schema[:extra_args?]
},
argv,
composing_task_name
)
else
_ ->
recursively_compose_schema(
%{schema | composes: rest, extra_args?: true},
argv,
parent
)
end
end

defp merge_schemas(schema, composing_schema, task, composing_task) do
schema = schema || []
composing_schema = composing_schema || []

Keyword.merge(composing_schema, schema, fn key, composing_value, schema_value ->
Logger.warning("""
#{composing_task} has a different configuration for argument: #{key}. Using #{task}'s configuration.
#{composing_task}: #{composing_value}
#{task}: #{schema_value}
""")

schema_value
end)
end

defp merge_aliases(aliases, composing_aliases, task, composing_task) do
aliases = aliases || []
composing_aliases = composing_aliases || []

Keyword.merge(composing_aliases, aliases, fn key, composing_value, schema_value ->
Logger.warning("""
#{composing_task} has a different configuration for alias: #{key}. Using #{task}'s configuration.
#{composing_task}: #{composing_value}
#{task}: #{schema_value}
""")

schema_value
end)
end
end

0 comments on commit fd475c3

Please sign in to comment.