Current section

Files

Jump to
cli_mate lib mix tasks cli.embed.ex
Raw

lib/mix/tasks/cli.embed.ex

defmodule Mix.Tasks.Cli.Embed do
alias CliMate.CLI
use Mix.Task
@shortdoc "Copies the CLI code into your own application."
@command module: __MODULE__,
doc: @shortdoc,
options: [
extend: [
type: :boolean,
default: false,
doc: """
When true, the base CLI module will be defined as `<prefix>.Base`
and will export an extend/0 macro. You will have to define your
main CLI module and call `require(<prefix>.Base).extend()` from
there.
When false, the command will define the main CLI module as
`<prefix>` directly. The `extend/0` macro is still included.
"""
],
moduledoc: [
type: :boolean,
default: true,
doc: """
When true, include @moduledoc attributes in the generated code.
When false, defines `@moduledoc false` in all generated modules.
"""
],
force: [
type: :boolean,
default: false,
short: :f,
doc: """
Actually writes generated code to disk. Without this option the
command only prints debug information.
"""
],
yes: [
type: :boolean,
default: false,
short: :y,
doc: """
Automatically accept prompts to overwrite files.
"""
]
],
arguments: [
prefix: [
doc: """
The root namespace for the generated modules.
Example: MyApp.CLI.
"""
],
path: [
doc: """
The base directory for the generated modules. When the --extend
option is not provided, the base module is defined as <path>.ex,
that is outside of said directory.
"""
]
]
@moduledoc """
#{@shortdoc}
#{CliMate.CLI.format_usage(@command, format: :moduledoc)}
"""
@impl true
def run(argv) do
%{options: opts, arguments: args} = CLI.parse_or_halt!(argv, @command)
opts = Map.put(opts, :banner, generate_banner(argv))
namespace_replacement =
args.prefix
|> String.split(".")
|> Enum.map(&String.to_atom/1)
alias_replacement =
if opts.extend do
{namespace_replacement, namespace_replacement ++ [:Base]}
else
{namespace_replacement, namespace_replacement}
end
target_root_path = Path.absname(args.path)
source_root_path =
__ENV__.file
|> Path.dirname()
|> Path.join("../../cli_mate/cli")
|> Path.expand()
source_root_size = byte_size(source_root_path)
source_specs =
source_root_path
|> Path.join("**/*.ex")
|> Path.wildcard()
|> Enum.map(fn source_path ->
<<^source_root_path::binary-size(^source_root_size), target_sub_path::binary>> =
source_path
target_path = Path.relative_to_cwd(target_root_path <> target_sub_path)
%{
alias_replacement: alias_replacement,
source_code: read_source(source_path, opts),
source_path: source_path,
target_path: target_path
}
end)
main_source = source_root_path <> ".ex"
main_file_spec =
if opts.extend do
%{
source_path: main_source,
target_path: target_root_path <> "/base.ex",
source_code: read_source(main_source, opts),
alias_replacement: alias_replacement
}
else
%{
source_path: main_source,
target_path: target_root_path <> ".ex",
source_code: read_source(main_source, opts),
alias_replacement: alias_replacement
}
end
source_specs = [main_file_spec | source_specs]
written_specs =
Enum.flat_map(source_specs, fn spec ->
case handle_source(spec, opts) do
true -> [spec]
false -> []
end
end)
written_files = Enum.map(written_specs, & &1.target_path)
# The generated modules are paritally formatted but they are as code blocks.
# Mix format will finalize the format for acutal files.
Mix.Task.run("format", written_files)
end
defp handle_source(spec, opts) do
target_exists? = File.exists?(spec.target_path)
cond do
opts.force ->
maybe_write_module(target_exists?, spec, opts)
target_exists? ->
CLI.writeln("would create #{spec.target_path} (exists)")
false
:other ->
CLI.writeln("would create #{spec.target_path}")
false
end
end
defp ask_overwrite(target_path) do
Mix.Shell.IO.yes?("file #{target_path} exists, overwrite?", default: :no)
end
defp maybe_write_module(true = _target_exists?, spec, opts) do
if opts.yes || ask_overwrite(spec.target_path) do
:ok = write_module(spec, opts)
CLI.writeln("created #{spec.target_path} (overwrite)")
true
else
CLI.warn("skipped file #{spec.target_path} (exists)")
false
end
end
defp maybe_write_module(false = _target_exists?, spec, opts) do
:ok = write_module(spec, opts)
CLI.writeln("created #{spec.target_path}")
false
end
defp write_module(spec, opts) do
module_code = generate_module(spec, opts)
File.mkdir_p!(Path.dirname(spec.target_path))
File.write!(spec.target_path, module_code)
end
defp generate_module(spec, opts) do
{forms, comments} =
Code.string_to_quoted_with_comments!(spec.source_code,
token_metadata: true,
literal_encoder: &{:ok, {:__block__, &2, [&1]}},
unescape: false,
columns: true
)
fmt_opts = formatter_options()
line_length = Keyword.get(fmt_opts, :line_length, 98)
forms =
if opts.moduledoc do
forms
else
Macro.postwalk(forms, &strip_docs/1)
end
forms = Macro.postwalk(forms, &replace_aliases(&1, spec.alias_replacement))
module_code =
forms
|> Code.quoted_to_algebra(
[comments: comments, migrate_charlists_as_sigils: true, escape: false] ++
formatter_options()
)
|> Inspect.Algebra.format(line_length)
|> IO.iodata_to_binary()
opts.banner <> module_code
end
defp strip_docs({:moduledoc, meta, _}), do: {:moduledoc, meta, [false]}
defp strip_docs({:@, attr_meta, [{:doc_if_moduledoc, skip_meta, value}]})
when not is_nil(value) do
{:@, attr_meta, [{:doc_if_moduledoc, skip_meta, [{:__block__, skip_meta, [false]}]}]}
end
defp strip_docs(form), do: form
# replacement of the exact alias
defp replace_aliases({:__aliases__, meta, [:CliMate, :CLI]}, {_, main_mod_replacement}) do
{:__aliases__, meta, main_mod_replacement}
end
# replacement of submodule
defp replace_aliases({:__aliases__, meta, [:CliMate, :CLI | rest]}, {namespace_replacement, _}) do
{:__aliases__, meta, namespace_replacement ++ rest}
end
defp replace_aliases(form, _), do: form
defp formatter_options do
path = ".formatter.exs"
with true <- File.regular?(path),
{opts, _} <- Code.eval_file(path),
true <- Keyword.keyword?(opts) do
Keyword.take(opts, [:locals_without_parens])
else
_ -> []
end
end
defp read_source(path, %{force: true}), do: File.read!(path)
defp read_source(_path, _), do: []
def generate_banner(argv) do
quoted =
Enum.map_intersperse(argv, " ", fn arg ->
if String.match?(arg, ~r/\s/),
do: inspect(arg),
else: arg
end)
"""
# This file was generated by CliMate. Do not edit this file directly or your
# changes may be lost.
#
# Regenerate the files with the same command:
#
# mix cli.embed #{quoted}
#
"""
end
end