diff --git a/lib/igniter.ex b/lib/igniter.ex index 76a4429..87e9baa 100644 --- a/lib/igniter.ex +++ b/lib/igniter.ex @@ -1180,59 +1180,72 @@ defmodule Igniter do source -> source end - if Rewrite.Source.from?(source, :string) do - content_lines = - source - |> Rewrite.Source.get(:content) - |> String.split("\n") - - space_padding = - content_lines - |> length() - |> to_string() - |> String.length() - - diffish_looking_text = - content_lines - |> Enum.with_index(1) - |> Enum.map_join(fn {line, line_number} -> - IO.ANSI.format( - [ - String.pad_trailing(to_string(line_number), space_padding), - " ", - :yellow, - "|", - :green, - line, - "\n" - ], - color? - ) - end) + cond do + Rewrite.Source.from?(source, :string) && + String.valid?(Rewrite.Source.get(source, :content)) -> + content_lines = + source + |> Rewrite.Source.get(:content) + |> String.split("\n") + + space_padding = + content_lines + |> length() + |> to_string() + |> String.length() + + diffish_looking_text = + content_lines + |> Enum.with_index(1) + |> Enum.map_join(fn {line, line_number} -> + IO.ANSI.format( + [ + String.pad_trailing(to_string(line_number), space_padding), + " ", + :yellow, + "|", + :green, + line, + "\n" + ], + color? + ) + end) - if String.trim(diffish_looking_text) != "" do - """ + if String.trim(diffish_looking_text) != "" do + """ - Create: #{Rewrite.Source.get(source, :path)} + Create: #{Rewrite.Source.get(source, :path)} - #{diffish_looking_text} - """ - else - "" - end - else - diff = Rewrite.Source.diff(source, color: color?) |> IO.iodata_to_binary() + #{diffish_looking_text} + """ + else + "" + end - if String.trim(diff) != "" do - """ + String.valid?(Rewrite.Source.get(source, :content)) -> + diff = Rewrite.Source.diff(source, color: color?) |> IO.iodata_to_binary() + + if String.trim(diff) != "" do + """ - Update: #{Rewrite.Source.get(source, :path)} + Update: #{Rewrite.Source.get(source, :path)} - #{diff} + #{diff} + """ + else + "" + end + + !String.valid?(Rewrite.Source.get(source, :content)) -> """ - else + Create: #{Rewrite.Source.get(source, :path)} + + (content diff can't be displayed) + """ + + :else -> "" - end end end) end diff --git a/lib/igniter/phoenix/generator.ex b/lib/igniter/phoenix/generator.ex new file mode 100644 index 0000000..644c18b --- /dev/null +++ b/lib/igniter/phoenix/generator.ex @@ -0,0 +1,116 @@ +defmodule Igniter.Phoenix.Generator do + @moduledoc false + # Wrap Phx.New.Generator + # https://github.com/phoenixframework/phoenix/blob/7586cbee9e37afbe0b3cdbd560b9e6aa60d32bf6/installer/lib/phx_new/generator.ex#L69 + + def copy_from(igniter, project, mod, name) when is_atom(name) do + mapping = mod.template_files(name) + + templates = + for {format, _project_location, files} <- mapping, + {source, target_path} <- files, + source = to_string(source) do + target = expand_path_with_bindings(target_path, project) + {format, source, target} + end + + Enum.reduce(templates, igniter, fn {format, source, target}, acc -> + case format do + :keep -> + acc + + :text -> + contents = mod.render(name, source, project.binding) + Igniter.create_new_file(acc, target, contents, on_exists: :overwrite) + + :config -> + contents = mod.render(name, source, project.binding) + config_inject(acc, target, contents) + + :prod_config -> + contents = mod.render(name, source, project.binding) + prod_only_config_inject(acc, target, contents) + + :eex -> + contents = mod.render(name, source, project.binding) + Igniter.create_new_file(acc, target, contents, on_exists: :overwrite) + end + end) + end + + defp expand_path_with_bindings(path, project) do + Regex.replace(Regex.recompile!(~r/:[a-zA-Z0-9_]+/), path, fn ":" <> key, _ -> + project |> Map.fetch!(:"#{key}") |> to_string() + end) + end + + defp config_inject(igniter, file, to_inject) do + patterns = [ + """ + import Config + __cursor__() + """ + ] + + Igniter.create_or_update_elixir_file(igniter, file, to_inject, fn zipper -> + case Igniter.Code.Common.move_to_cursor_match_in_scope(zipper, patterns) do + {:ok, zipper} -> + {:ok, Igniter.Code.Common.add_code(zipper, to_inject)} + + _ -> + {:warning, + """ + Could not automatically inject the following config into #{file} + + #{to_inject} + """} + end + end) + end + + defp prod_only_config_inject(igniter, file, to_inject) do + patterns = [ + """ + if config_env() == :prod do + __cursor__() + end + """, + """ + if :prod == config_env() do + __cursor__() + end + """ + ] + + Igniter.create_or_update_elixir_file(igniter, file, to_inject, fn zipper -> + case Igniter.Code.Common.move_to_cursor_match_in_scope(zipper, patterns) do + {:ok, zipper} -> + {:ok, Igniter.Code.Common.add_code(zipper, to_inject)} + + _ -> + {:warning, + """ + Could not automatically inject the following config into #{file} + + #{to_inject} + """} + end + end) + end + + def gen_ecto_config(igniter, %{binding: binding}) do + adapter_config = binding[:adapter_config] + + config_inject(igniter, "config/dev.exs", """ + # Configure your database + config :#{binding[:app_name]}, #{binding[:app_module]}.Repo#{kw_to_config(adapter_config[:dev])} + """) + end + + defp kw_to_config(kw) do + Enum.map(kw, fn + {k, {:literal, v}} -> ",\n #{k}: #{v}" + {k, v} -> ",\n #{k}: #{inspect(v)}" + end) + end +end diff --git a/lib/igniter/phoenix/single.ex b/lib/igniter/phoenix/single.ex new file mode 100644 index 0000000..9b91356 --- /dev/null +++ b/lib/igniter/phoenix/single.ex @@ -0,0 +1,71 @@ +defmodule Igniter.Phoenix.Single do + @moduledoc false + # Wrap Phx.New.Single + # https://github.com/phoenixframework/phoenix/blob/7586cbee9e37afbe0b3cdbd560b9e6aa60d32bf6/installer/lib/phx_new/single.ex + + alias Phx.New.Project + alias Igniter.Phoenix.Generator + + @mod Phx.New.Single + + def generate(igniter, project) do + generators = [ + {true, &gen_new/2}, + {Project.ecto?(project), &gen_ecto/2}, + {Project.html?(project), &gen_html/2}, + {Project.mailer?(project), &gen_mailer/2}, + {Project.gettext?(project), &gen_gettext/2}, + {true, &gen_assets/2} + ] + + Enum.reduce(generators, igniter, fn + {true, gen_fun}, acc -> gen_fun.(acc, project) + _, acc -> acc + end) + end + + def gen_new(igniter, project) do + Generator.copy_from(igniter, project, @mod, :new) + end + + def gen_ecto(igniter, project) do + igniter + |> Generator.copy_from(project, @mod, :ecto) + |> Generator.gen_ecto_config(project) + end + + def gen_html(igniter, project) do + Generator.copy_from(igniter, project, @mod, :html) + end + + def gen_mailer(igniter, project) do + Generator.copy_from(igniter, project, @mod, :mailer) + end + + def gen_gettext(igniter, project) do + Generator.copy_from(igniter, project, @mod, :gettext) + end + + def gen_assets(igniter, project) do + javascript? = Project.javascript?(project) + css? = Project.css?(project) + html? = Project.html?(project) + + igniter = Generator.copy_from(igniter, project, @mod, :static) + + igniter = + if html? or javascript? do + command = if javascript?, do: :js, else: :no_js + Generator.copy_from(igniter, project, @mod, command) + else + igniter + end + + if html? or css? do + command = if css?, do: :css, else: :no_css + Generator.copy_from(igniter, project, @mod, command) + else + igniter + end + end +end diff --git a/lib/igniter/project/config.ex b/lib/igniter/project/config.ex index 7559b6c..af64db6 100644 --- a/lib/igniter/project/config.ex +++ b/lib/igniter/project/config.ex @@ -189,6 +189,7 @@ defmodule Igniter.Project.Config do end) end + @doc false defp ensure_default_configs_exist(igniter, file) when file in ["config/dev.exs", "config/test.exs", "config/prod.exs"] do igniter diff --git a/lib/mix/tasks/igniter.install_phoenix.ex b/lib/mix/tasks/igniter.install_phoenix.ex new file mode 100644 index 0000000..7bb8f09 --- /dev/null +++ b/lib/mix/tasks/igniter.install_phoenix.ex @@ -0,0 +1,181 @@ +defmodule Mix.Tasks.Igniter.Phx.Install do + use Igniter.Mix.Task + + @example "mix igniter.phx.install . --module MyApp --app my_app" + @shortdoc "Creates a new Phoenix project in the current application." + + @moduledoc """ + #{@shortdoc} + + ## Example + + ```bash + #{@example} + ``` + + ## Options + + * `--app` - the name of the OTP application + + * `--module` - the name of the base module in + the generated skeleton + + * `--database` - specify the database adapter for Ecto. One of: + + * `postgres` - via https://github.com/elixir-ecto/postgrex + * `mysql` - via https://github.com/elixir-ecto/myxql + * `mssql` - via https://github.com/livehelpnow/tds + * `sqlite3` - via https://github.com/elixir-sqlite/ecto_sqlite3 + + Please check the driver docs for more information + and requirements. Defaults to "postgres". + + * `--adapter` - specify the http adapter. One of: + * `cowboy` - via https://github.com/elixir-plug/plug_cowboy + * `bandit` - via https://github.com/mtrudel/bandit + + Please check the adapter docs for more information + and requirements. Defaults to "bandit". + + * `--no-assets` - equivalent to `--no-esbuild` and `--no-tailwind` + + * `--no-dashboard` - do not include Phoenix.LiveDashboard + + * `--no-ecto` - do not generate Ecto files + + * `--no-esbuild` - do not include esbuild dependencies and assets. + We do not recommend setting this option, unless for API only + applications, as doing so requires you to manually add and + track JavaScript dependencies + + * `--no-gettext` - do not generate gettext files + + * `--no-html` - do not generate HTML views + + * `--no-live` - comment out LiveView socket setup in your Endpoint + and assets/js/app.js. Automatically disabled if --no-html is given + + * `--no-mailer` - do not generate Swoosh mailer files + + * `--no-tailwind` - do not include tailwind dependencies and assets. + The generated markup will still include Tailwind CSS classes, those + are left-in as reference for the subsequent styling of your layout + and components + + * `--binary-id` - use `binary_id` as primary key type in Ecto schemas + + * `--verbose` - use verbose output + + When passing the `--no-ecto` flag, Phoenix generators such as + `phx.gen.html`, `phx.gen.json`, `phx.gen.live`, and `phx.gen.context` + may no longer work as expected as they generate context files that rely + on Ecto for the database access. In those cases, you can pass the + `--no-context` flag to generate most of the HTML and JSON files + but skip the context, allowing you to fill in the blanks as desired. + + Similarly, if `--no-html` is given, the files generated by + `phx.gen.html` will no longer work, as important HTML components + will be missing. + + """ + + def info(_argv, _source) do + %Igniter.Mix.Task.Info{ + group: :igniter, + example: @example, + positional: [:base_path], + schema: [ + app: :string, + module: :string, + database: :string, + adapter: :string, + assets: :boolean, + dashboard: :boolean, + ecto: :boolean, + esbuild: :boolean, + gettext: :boolean, + html: :boolean, + live: :boolean, + mailer: :boolean, + tailwind: :boolean, + binary_id: :boolean, + verbose: :boolean + ] + } + end + + def igniter(igniter) do + elixir_version_check!() + + if !Code.ensure_loaded?(Phx.New.Generator) do + Mix.raise(""" + Phoenix installer is not available. Please install it before proceding: + + mix archive.install hex phx_new + + """) + end + + if igniter.args.options[:umbrella] do + Mix.raise("Umbrella projects are not supported yet.") + end + + %{base_path: base_path} = igniter.args.positional + + generate(igniter, base_path, {Phx.New.Single, Igniter.Phoenix.Single}, igniter.args.options) + end + + defp generate(igniter, base_path, {phx_generator, igniter_generator}, opts) do + project = + base_path + |> Phx.New.Project.new(opts) + |> phx_generator.prepare_project() + |> Phx.New.Generator.put_binding() + |> validate_project() + + igniter + |> Igniter.compose_task("igniter.add_extension", ["phoenix"]) + |> igniter_generator.generate(project) + end + + defp validate_project(%{opts: opts} = project) do + check_app_name!(project.app, !!opts[:app]) + check_module_name_validity!(project.root_mod) + + project + end + + defp check_app_name!(name, from_app_flag) do + unless name =~ Regex.recompile!(~r/^[a-z][a-z0-9_]*$/) do + extra = + if !from_app_flag do + ". The application name is inferred from the path, if you'd like to " <> + "explicitly name the application then use the `--app APP` option." + else + "" + end + + Mix.raise( + "Application name must start with a letter and have only lowercase " <> + "letters, numbers and underscore, got: #{inspect(name)}" <> extra + ) + end + end + + defp check_module_name_validity!(name) do + unless inspect(name) =~ Regex.recompile!(~r/^[A-Z]\w*(\.[A-Z]\w*)*$/) do + Mix.raise( + "Module name must be a valid Elixir alias (for example: Foo.Bar), got: #{inspect(name)}" + ) + end + end + + defp elixir_version_check! do + unless Version.match?(System.version(), "~> 1.15") do + Mix.raise( + "mix igniter.phx.install requires at least Elixir v1.15\n " <> + "You have #{System.version()}. Please update accordingly." + ) + end + end +end diff --git a/mix.exs b/mix.exs index a7f22c8..5e1fa99 100644 --- a/mix.exs +++ b/mix.exs @@ -105,6 +105,7 @@ defmodule Igniter.MixProject do {:spitfire, "~> 0.1 and >= 0.1.3"}, {:sourceror, "~> 1.4"}, {:jason, "~> 1.4"}, + {:phx_new, "~> 1.7", optional: true}, # Dev/Test dependencies {:eflame, "~> 1.0", only: [:dev, :test]}, {:ex_doc, "~> 0.32", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index c54774b..533fdf1 100644 --- a/mix.lock +++ b/mix.lock @@ -24,6 +24,7 @@ "mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"}, "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "phx_new": {:hex, :phx_new, "1.7.14", "30d2d38b78bb762452595fe2e32f3a4a838f26e87713024840059884204ff141", [:mix], [], "hexpm", "e1a8b3839a9a2d94bceb95d96ca1f175264c751405fee8f39a4e67b379314a39"}, "rewrite": {:hex, :rewrite, "1.1.2", "f5a5d10f5fed1491a6ff48e078d4585882695962ccc9e6c779bae025d1f92eda", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "7f8b94b1e3528d0a47b3e8b7bfeca559d2948a65fa7418a9ad7d7712703d39d4"}, "sourceror": {:hex, :sourceror, "1.7.1", "599d78f4cc2be7d55c9c4fd0a8d772fd0478e3a50e726697c20d13d02aa056d4", [:mix], [], "hexpm", "cd6f268fe29fa00afbc535e215158680a0662b357dc784646d7dff28ac65a0fc"}, "spitfire": {:hex, :spitfire, "0.1.4", "8fe0df66e735323e4f2a56e719603391b160dd68efd922cadfbb85a2cf6c68af", [:mix], [], "hexpm", "d40d850f4ede5235084876246756b90c7bcd12994111d57c55e2e1e23ac3fe61"}, diff --git a/test/mix/tasks/igniter.phx.install_test.exs b/test/mix/tasks/igniter.phx.install_test.exs new file mode 100644 index 0000000..83dd99c --- /dev/null +++ b/test/mix/tasks/igniter.phx.install_test.exs @@ -0,0 +1,25 @@ +defmodule Mix.Tasks.Igniter.Phx.InstallTest do + use ExUnit.Case + import Igniter.Test + + test "create files" do + igniter = Igniter.compose_task(test_project(), "igniter.phx.install", ["my_app"]) + assert Enum.count(igniter.rewrite.sources) == 47 + end + + test "inject config" do + test_project() + |> Igniter.compose_task("igniter.phx.install", ["my_app"]) + |> assert_has_patch("config/dev.exs", """ + 22 | # Configure your database + 23 | config :my_app, MyApp.Repo, + 24 | username: "postgres", + 25 | password: "postgres", + 26 | hostname: "localhost", + 27 | database: "my_app_dev", + 28 | stacktrace: true, + 29 | show_sensitive_data_on_connection_error: true, + 30 | pool_size: 10 + """) + end +end