From f1e0fffc4b66f9816793bf12e9fcb62d1b414ade Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Mon, 3 Jun 2024 13:13:49 -0400 Subject: [PATCH] improvement: add installer archive --- .gitignore | 27 ++++++ installer/README.md | 17 ++++ installer/lib/mix/tasks/igniter.new.ex | 87 +++++++++++++++++ installer/mix.exs | 41 ++++++++ installer/mix.lock | 8 ++ lib/config.ex | 21 +--- lib/deps.ex | 80 +++++----------- lib/formatter.ex | 127 +++++++++++-------------- lib/igniter.ex | 108 +++++++++++++++------ lib/install.ex | 17 +--- lib/version.ex | 13 +++ 11 files changed, 359 insertions(+), 187 deletions(-) create mode 100644 installer/README.md create mode 100644 installer/lib/mix/tasks/igniter.new.ex create mode 100644 installer/mix.exs create mode 100644 installer/mix.lock create mode 100644 lib/version.ex diff --git a/.gitignore b/.gitignore index 8f7495a..f80480a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,30 @@ igniter-*.tar # Temporary files, for example, from tests. /tmp/ + +# The directory Mix will write compiled artifacts to. +/installer/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/installer/cover/ + +# The directory Mix downloads your dependencies sources to. +/installer/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/installer/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/installer/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +installer/erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +igniter-*.tar + +# Temporary files, for example, from tests. +installer/tmp/ diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..c3f265f --- /dev/null +++ b/installer/README.md @@ -0,0 +1,17 @@ +## mix igniter.new + +Provides `igniter.new` installer as an archive. + +To install from Hex, run: + + $ mix archive.install hex igniter_new + +To build and install it locally, +ensure any previous archive versions are removed: + + $ mix archive.uninstall phx_new + +Then run: + + $ cd installer + $ MIX_ENV=prod mix do archive.build, archive.install diff --git a/installer/lib/mix/tasks/igniter.new.ex b/installer/lib/mix/tasks/igniter.new.ex new file mode 100644 index 0000000..60e4166 --- /dev/null +++ b/installer/lib/mix/tasks/igniter.new.ex @@ -0,0 +1,87 @@ +defmodule Mix.Tasks.Igniter.New do + use Mix.Task + + @igniter_version Mix.Project.config()[:version] + + @shortdoc "Creates a new Igniter application" + def run([name | _ ] = argv) do + {options, argv, _errors} = OptionParser.parse(argv, + strict: [install: :keep, local: :string, example: :boolean], + aliases: [i: :install, l: :local, e: :example] + ) + + install = + options[:install] + |> List.wrap() + |> Enum.join(",") + |> String.split(",", trim: true) + + if File.exists?(name) do + Mix.shell().error(""" + The directory #{name} already exists. You must either: + 1. remove or move it + 2. If you are trying to modify an existing project add `{:igniter` to the project, if it is not + already added, and then run `mix igniter.install #{Enum.join(install, ",")}` inside the project + """) + + exit({:shutdown, 1}) + end + + exit = Mix.shell().cmd("mix new #{Enum.join(argv, " ")}") + + if exit == 0 do + version_requirement = + if options[:local] do + local = Path.join(["..", Path.relative_to_cwd(options[:local])]) + "path: #{inspect(local)}" + else + inspect(version_requirement()) + end + + File.cd!(name) + + contents = + "mix.exs" + |> File.read!() + + if String.contains?(contents, "{:igniter") do + Mix.shell().info("It looks like the project already exists and igniter is already installed, not adding it to deps.") + else + new_contents = + String.replace(contents, "defp deps do\n [\n", "defp deps do\n [\n{:igniter, #{version_requirement}}\n") + + File.write!("mix.exs", new_contents) + end + + Mix.shell().cmd("mix deps.get") + Mix.shell().cmd("mix compile") + + unless Enum.empty?(install) do + example = + if options[:example] do + "--example" + end + Mix.shell().cmd("mix igniter.install #{Enum.join(install, ",")} --yes #{example}" |> IO.inspect()) + end + + else + Mix.shell().info("Aborting command because associated `mix new` command failed.") + + exit({:shutdown, 1}) + end + + :ok + end + + defp version_requirement do + @igniter_version + |> Version.parse!() + |> case do + %Version{major: 0, minor: minor} -> + "~> 0.#{minor}" + + %Version{major: major} -> + "~> #{major}.0" + end + end +end diff --git a/installer/mix.exs b/installer/mix.exs new file mode 100644 index 0000000..9b7c27b --- /dev/null +++ b/installer/mix.exs @@ -0,0 +1,41 @@ +defmodule Igniter.New.MixProject do + use Mix.Project + + @version "0.1.0" + @scm_url "https://github.com/ash-project/igniter" + + def project do + [ + app: :igniter_new, + start_permanent: Mix.env() == :prod, + version: @version, + elixir: "~> 1.14", + deps: deps(), + package: [ + maintainers: ["Zach Daniel"], + licenses: ["MIT"], + links: %{"GitHub" => @scm_url}, + files: ~w(lib templates mix.exs README.md) + ], + preferred_cli_env: [docs: :docs], + source_url: @scm_url, + docs: docs(), + homepage_url: "https://www.ash-hq.org", + description: """ + Create a new mix project with igniter, and run igniter installers in one command! + """ + ] + end + + def deps do + [ + {:ex_doc, "~> 0.24", only: :docs} + ] + end + + defp docs do + [ + source_url_pattern: "#{@scm_url}/blob/v#{@version}/installer/%{path}#L%{line}" + ] + end +end diff --git a/installer/mix.lock b/installer/mix.lock new file mode 100644 index 0000000..ae885bb --- /dev/null +++ b/installer/mix.lock @@ -0,0 +1,8 @@ +%{ + "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, + "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, +} diff --git a/lib/config.ex b/lib/config.ex index ef0f1f2..a240928 100644 --- a/lib/config.ex +++ b/lib/config.ex @@ -12,18 +12,15 @@ defmodule Igniter.Config do igniter |> Igniter.include_or_create_elixir_file(file_path, "import Config\n") - |> Igniter.update_file(file_path, fn source -> - quoted = Rewrite.Source.get(source, :quoted) - zipper = Zipper.zip(quoted) - + |> Igniter.update_elixir_file(file_path, fn zipper -> case try_update_three_arg(zipper, config_path, app_name, updater) do {:ok, zipper} -> - Rewrite.Source.update(source, :configure, :quoted, Zipper.root(zipper)) + zipper :error -> case try_update_two_arg(zipper, config_path, app_name, value, updater) do {:ok, zipper} -> - Rewrite.Source.update(source, :configure, :quoted, Zipper.root(zipper)) + zipper :error -> # add new code here @@ -32,17 +29,7 @@ defmodule Igniter.Config do config = {:config, [], [app_name, [{first, Igniter.Common.keywordify(rest, value)}]]} - code = - zipper - |> Igniter.Common.add_code(config) - |> Zipper.root() - - Rewrite.Source.update( - source, - :configure, - :quoted, - code - ) + Igniter.Common.add_code(zipper, config) end end end) diff --git a/lib/deps.ex b/lib/deps.ex index 7c8e0d4..2c585f6 100644 --- a/lib/deps.ex +++ b/lib/deps.ex @@ -63,70 +63,40 @@ defmodule Igniter.Deps do defp remove_dependency(igniter, name) do igniter - |> Igniter.update_file("mix.exs", fn source -> - quoted = Rewrite.Source.get(source, :quoted) - - new_quoted = - with zipper <- Zipper.zip(quoted), - {:ok, zipper} <- Common.move_to_module_using(zipper, Mix.Project), - {:ok, zipper} <- Common.move_to_defp(zipper, :deps, 0), - true <- Common.node_matches_pattern?(zipper, value when is_list(value)), - current_declaration_index when not is_nil(current_declaration_index) <- - Common.find_list_item_index(zipper, fn item -> - if Common.tuple?(item) do - first_elem = Common.tuple_elem(item, 0) - first_elem && Common.node_matches_pattern?(first_elem, ^name) - end - end) do - zipper - |> Common.remove_index(current_declaration_index) - |> Zipper.root() - else - _ -> - quoted - end - - if new_quoted == quoted do - Rewrite.Source.add_issue( - source, - "Failed to remove dependency #{inspect(name)}" - ) + |> Igniter.update_elixir_file("mix.exs", fn zipper -> + with {:ok, zipper} <- Common.move_to_module_using(zipper, Mix.Project), + {:ok, zipper} <- Common.move_to_defp(zipper, :deps, 0), + true <- Common.node_matches_pattern?(zipper, value when is_list(value)), + current_declaration_index when not is_nil(current_declaration_index) <- + Common.find_list_item_index(zipper, fn item -> + if Common.tuple?(item) do + first_elem = Common.tuple_elem(item, 0) + first_elem && Common.node_matches_pattern?(first_elem, ^name) + end + end) do + Common.remove_index(zipper, current_declaration_index) else - Rewrite.Source.update(source, :add_dependency, :quoted, new_quoted) + _ -> + {:error, "Failed to remove dependency #{inspect(name)}"} end end) end defp do_add_dependency(igniter, name, version) do igniter - |> Igniter.update_file("mix.exs", fn source -> - quoted = Rewrite.Source.get(source, :quoted) + |> Igniter.update_elixir_file("mix.exs", fn zipper -> + with {:ok, zipper} <- Common.move_to_module_using(zipper, Mix.Project), + {:ok, zipper} <- Common.move_to_defp(zipper, :deps, 0), + true <- Common.node_matches_pattern?(zipper, value when is_list(value)) do + quoted = + quote do + {unquote(name), unquote(version)} + end - new_quoted = - with zipper <- Zipper.zip(quoted), - {:ok, zipper} <- Common.move_to_module_using(zipper, Mix.Project), - {:ok, zipper} <- Common.move_to_defp(zipper, :deps, 0), - true <- Common.node_matches_pattern?(zipper, value when is_list(value)) do - quoted = - quote do - {unquote(name), unquote(version)} - end - - zipper - |> Common.prepend_to_list(quoted) - |> Zipper.root() - else - _ -> - quoted - end - - if new_quoted == quoted do - Rewrite.Source.add_issue( - source, - "Failed to add dependency #{inspect({inspect(name), inspect(version)})}" - ) + Common.prepend_to_list(zipper, quoted) else - Rewrite.Source.update(source, :add_dependency, :quoted, new_quoted) + _ -> + {:error, "Failed to add dependency #{inspect({inspect(name), inspect(version)})}"} end end) end diff --git a/lib/formatter.ex b/lib/formatter.ex index baf0789..b56d207 100644 --- a/lib/formatter.ex +++ b/lib/formatter.ex @@ -13,87 +13,72 @@ defmodule Igniter.Formatter do def import_dep(igniter, dep) do igniter |> Igniter.include_or_create_elixir_file(".formatter.exs", @default_formatter) - |> Igniter.update_file(".formatter.exs", fn source -> - quoted = Rewrite.Source.get(source, :quoted) - zipper = Zipper.zip(quoted) - - new_code = - zipper - |> Zipper.down() - |> case do - nil -> - code = - quote do - [import_deps: [unquote(dep)]] - end - - zipper - |> Igniter.Common.add_code(code) - - zipper -> - zipper - |> Zipper.rightmost() - |> Common.put_in_keyword([:import_deps], [dep], fn nested_zipper -> - Igniter.Common.prepend_new_to_list( - nested_zipper, - dep - ) - end) - |> case do - {:ok, zipper} -> - zipper - - :error -> - zipper + |> Igniter.update_elixir_file(".formatter.exs", fn zipper -> + zipper + |> Zipper.down() + |> case do + nil -> + code = + quote do + [import_deps: [unquote(dep)]] end - end - |> Zipper.root() - Rewrite.Source.update(source, :import_formatter_dep, :quoted, new_code) + Igniter.Common.add_code(zipper, code) + + zipper -> + zipper + |> Zipper.rightmost() + |> Common.put_in_keyword([:import_deps], [dep], fn nested_zipper -> + Igniter.Common.prepend_new_to_list( + nested_zipper, + dep + ) + end) + |> case do + {:ok, zipper} -> + zipper + + :error -> + zipper + end + end end) end def add_formatter_plugin(igniter, plugin) do igniter |> Igniter.include_or_create_elixir_file(".formatter.exs", @default_formatter) - |> Igniter.update_file(".formatter.exs", fn source -> - quoted = Rewrite.Source.get(source, :quoted) - zipper = Zipper.zip(quoted) - - new_code = - zipper - |> Zipper.down() - |> case do - nil -> - code = - quote do - [plugins: [unquote(plugin)]] - end - - zipper - |> Igniter.Common.add_code(code) - - zipper -> - zipper - |> Zipper.rightmost() - |> Common.put_in_keyword([:plugins], [Spark.Formatter], fn nested_zipper -> - Igniter.Common.prepend_new_to_list( - nested_zipper, - Spark.Formatter, - &Igniter.Common.equal_modules?/2 - ) - end) - |> case do - {:ok, zipper} -> - zipper - - _ -> - zipper + |> Igniter.update_elixir_file(".formatter.exs", fn zipper -> + zipper + |> Zipper.down() + |> case do + nil -> + code = + quote do + [plugins: [unquote(plugin)]] end - end - |> Zipper.root() - Rewrite.Source.update(source, :add_formatter_plugin, :quoted, new_code) + zipper + |> Igniter.Common.add_code(code) + + zipper -> + zipper + |> Zipper.rightmost() + |> Common.put_in_keyword([:plugins], [Spark.Formatter], fn nested_zipper -> + Igniter.Common.prepend_new_to_list( + nested_zipper, + Spark.Formatter, + &Igniter.Common.equal_modules?/2 + ) + end) + |> case do + {:ok, zipper} -> + zipper + + _ -> + zipper + end + end end) end end diff --git a/lib/igniter.ex b/lib/igniter.ex index 7fd501e..e9b8564 100644 --- a/lib/igniter.ex +++ b/lib/igniter.ex @@ -53,6 +53,32 @@ defmodule Igniter do end end + def update_elixir_file(igniter, path, func) do + if Rewrite.has_source?(igniter.rewrite, path) do + %{ + igniter + | rewrite: + Rewrite.update!(igniter.rewrite, path, fn source -> + apply_func_with_zipper(source, func) + end) + } + else + if File.exists?(path) do + source = Rewrite.Source.Ex.read!(path) + + %{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)} + |> format(path) + |> Map.update!(:rewrite, fn rewrite -> + Rewrite.update!(rewrite, path, fn source -> + apply_func_with_zipper(source, func) + end) + end) + else + add_issue(igniter, "Required #{path} but it did not exist") + end + end + end + def update_file(igniter, path, func) do if Rewrite.has_source?(igniter.rewrite, path) do %{igniter | rewrite: Rewrite.update!(igniter.rewrite, path, func)} @@ -161,50 +187,52 @@ defmodule Igniter do end) |> case do [] -> - unless opts[:quiet_on_no_changes?] do + unless opts[:quiet_on_no_changes?] || "--yes" in argv do Mix.shell().info("\n#{title}: No proposed changes!\n") end :dry_run_with_no_changes sources -> - Mix.shell().info("\n#{title}: Proposed changes:\n") + if "--dry-run" in argv || "--yes" not in argv do + Mix.shell().info("\n#{title}: Proposed changes:\n") - Enum.each(sources, fn source -> - if Rewrite.Source.from?(source, :string) do - content_lines = - source - |> Rewrite.Source.get(:content) - |> String.split("\n") - |> Enum.with_index() + Enum.each(sources, fn source -> + 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() + 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 + 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)} #{IO.ANSI.yellow()}| #{IO.ANSI.green()}#{line}#{IO.ANSI.reset()}" - end) + "#{String.pad_trailing(to_string(line_number), space_padding)} #{IO.ANSI.yellow()}| #{IO.ANSI.green()}#{line}#{IO.ANSI.reset()}" + end) - Mix.shell().info(""" - Create: #{Rewrite.Source.get(source, :path)} + Mix.shell().info(""" + Create: #{Rewrite.Source.get(source, :path)} - #{diffish_looking_text} - """) - else - Mix.shell().info(""" - Update: #{Rewrite.Source.get(source, :path)} + #{diffish_looking_text} + """) + else + Mix.shell().info(""" + Update: #{Rewrite.Source.get(source, :path)} - #{Rewrite.Source.diff(source)} - """) - end - end) + #{Rewrite.Source.diff(source)} + """) + end + end) + end :dry_run_with_changes end @@ -439,4 +467,22 @@ defmodule Igniter do opts end + + defp apply_func_with_zipper(source, func) do + quoted = Rewrite.Source.get(source, :quoted) + zipper = Sourceror.Zipper.zip(quoted) + + case func.(zipper) do + %Sourceror.Zipper{} = zipper -> + Rewrite.Source.update( + source, + :configure, + :quoted, + Sourceror.Zipper.root(zipper) + ) + + {:error, error} -> + Rewrite.Source.add_issues(source, List.wrap(error)) + end + end end diff --git a/lib/install.ex b/lib/install.ex index 037b39c..1808d41 100644 --- a/lib/install.ex +++ b/lib/install.ex @@ -1,7 +1,7 @@ defmodule Igniter.Install do @moduledoc false @option_schema [ - switches: [ + strict: [ example: :boolean, dry_run: :boolean, yes: :boolean @@ -19,8 +19,8 @@ defmodule Igniter.Install do Application.ensure_all_started(:req) - {options, _} = - OptionParser.parse!(argv, @option_schema) + {options, _, _unprocessed_argv} = + OptionParser.parse(argv, @option_schema) argv = OptionParser.to_argv(options) @@ -42,16 +42,7 @@ defmodule Igniter.Install do | _ ] } -> - requirement = - version - |> Version.parse!() - |> case do - %Version{major: 0, minor: minor} -> - "~> 0.#{minor}" - - %Version{major: major} -> - "~> #{major}.0" - end + requirement = Igniter.Version.version_string_to_general_requirement(version) Igniter.Deps.add_dependency(igniter, install, requirement) diff --git a/lib/version.ex b/lib/version.ex new file mode 100644 index 0000000..1db84a5 --- /dev/null +++ b/lib/version.ex @@ -0,0 +1,13 @@ +defmodule Igniter.Version do + def version_string_to_general_requirement(version) do + version + |> Version.parse!() + |> case do + %Version{major: 0, minor: minor} -> + "~> 0.#{minor}" + + %Version{major: major} -> + "~> #{major}.0" + end + end +end