diff --git a/lib/igniter.ex b/lib/igniter.ex index a46080e..0681a68 100644 --- a/lib/igniter.ex +++ b/lib/igniter.ex @@ -1129,48 +1129,62 @@ defmodule Igniter do source -> source end - if Rewrite.Source.from?(source, :string) do - content_lines = - source - |> Rewrite.Source.get(:content) - |> String.split("\n") - |> Enum.with_index() - - space_padding = - content_lines - |> Enum.map(&elem(&1, 1)) - |> Enum.max() - |> to_string() - |> String.length() - - diffish_looking_text = - Enum.map_join(content_lines, "\n", fn {line, line_number_minus_one} -> - line_number = line_number_minus_one + 1 - - "#{String.pad_trailing(to_string(line_number), space_padding)} #{color(IO.ANSI.yellow(), color?)}| #{color(IO.ANSI.green(), color?)}#{line}#{color(IO.ANSI.reset(), 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") + |> Enum.with_index() + + space_padding = + content_lines + |> Enum.map(&elem(&1, 1)) + |> Enum.max() + |> to_string() + |> String.length() + + diffish_looking_text = + Enum.map_join(content_lines, "\n", fn {line, line_number_minus_one} -> + line_number = line_number_minus_one + 1 + + "#{String.pad_trailing(to_string(line_number), space_padding)} #{color(IO.ANSI.yellow(), color?)}| #{color(IO.ANSI.green(), color?)}#{line}#{color(IO.ANSI.reset(), color?)}" + end) - if String.trim(diffish_looking_text) != "" do - """ - Create: #{Rewrite.Source.get(source, :path)} + if String.trim(diffish_looking_text) != "" do + """ + 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 + + 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)} - if String.trim(diff) != "" do + #{diff} + """ + else + "" + end + + !String.valid?(Rewrite.Source.get(source, :content)) -> """ - Update: #{Rewrite.Source.get(source, :path)} + Create: #{Rewrite.Source.get(source, :path)} - #{diff} + (content diff can't be displayed) """ - else + + :else -> + dbg(source) "" - 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 e0a072a..57d88bc 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..9f72498 --- /dev/null +++ b/lib/mix/tasks/igniter.install_phoenix.ex @@ -0,0 +1,181 @@ +defmodule Mix.Tasks.Igniter.InstallPhoenix do + use Igniter.Mix.Task + + @example "mix igniter.install_phoenix . --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.install_phoenix 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 6cd62d7..4fd9c21 100644 --- a/mix.exs +++ b/mix.exs @@ -100,6 +100,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 d0ee40a..d1c3086 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.0.1", "2a249d703e47c050ad251fa43a3d019d4c08159ead95ec30ef48357ba88af609", [: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", "79869f0bdb22840cf233b99e0dc7b6682a35d7e4747bdf2e78d3bc156b2c7c14"}, "sourceror": {:hex, :sourceror, "1.7.1", "599d78f4cc2be7d55c9c4fd0a8d772fd0478e3a50e726697c20d13d02aa056d4", [:mix], [], "hexpm", "cd6f268fe29fa00afbc535e215158680a0662b357dc784646d7dff28ac65a0fc"}, "spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"}, diff --git a/test/mix/tasks/igniter.install_phoenix_test.exs b/test/mix/tasks/igniter.install_phoenix_test.exs new file mode 100644 index 0000000..b73b3f2 --- /dev/null +++ b/test/mix/tasks/igniter.install_phoenix_test.exs @@ -0,0 +1,25 @@ +defmodule Mix.Tasks.Igniter.InstallPhoenixTest do + use ExUnit.Case + import Igniter.Test + + test "create files" do + igniter = Igniter.compose_task(test_project(), "igniter.install_phoenix", ["my_app"]) + assert Enum.count(igniter.rewrite.sources) == 47 + end + + test "inject config" do + test_project() + |> Igniter.compose_task("igniter.install_phoenix", ["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