From 57ee3f9749a648ef89a2013c71d1f991496735c5 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Mon, 3 Jun 2024 23:14:36 -0400 Subject: [PATCH] WIP --- installer/lib/mix/tasks/igniter.new.ex | 2 +- lib/common.ex | 132 ++++++++++++++++++++-- lib/deps.ex | 45 ++++---- lib/igniter.ex | 74 ++++++++++++- lib/install.ex | 146 +++++++++++++++++++------ lib/version.ex | 29 ++++- mix.exs | 1 + test/config_test.exs | 2 +- 8 files changed, 359 insertions(+), 72 deletions(-) diff --git a/installer/lib/mix/tasks/igniter.new.ex b/installer/lib/mix/tasks/igniter.new.ex index 60e4166..8ba5da1 100644 --- a/installer/lib/mix/tasks/igniter.new.ex +++ b/installer/lib/mix/tasks/igniter.new.ex @@ -61,7 +61,7 @@ defmodule Mix.Tasks.Igniter.New do if options[:example] do "--example" end - Mix.shell().cmd("mix igniter.install #{Enum.join(install, ",")} --yes #{example}" |> IO.inspect()) + Mix.shell().cmd("mix igniter.install #{Enum.join(install, ",")} --yes #{example}") end else diff --git a/lib/common.ex b/lib/common.ex index bc7b378..0f6fcce 100644 --- a/lib/common.ex +++ b/lib/common.ex @@ -133,10 +133,26 @@ defmodule Igniter.Common do value = keywordify(rest, value) + to_append = + zipper + |> Zipper.subtree() + |> Zipper.node() + |> case do + [{{:__block__, meta, _}, {:__block__, _, _}} | _] -> + if meta[:format] do + {{:__block__, [format: meta[:format]], [key]}, {:__block__, [], [value]}} + else + {{:__block__, [], [key]}, {:__block__, [], [value]}} + end + + _ -> + {key, value} + end + {:ok, - prepend_to_list( + append_to_list( zipper, - [{key, value}] + to_append )} {:ok, zipper} -> @@ -162,10 +178,26 @@ defmodule Igniter.Common do end end) do :error -> + to_append = + zipper + |> Zipper.subtree() + |> Zipper.node() + |> case do + [{{:__block__, meta, _}, {:__block__, _, _}} | _] -> + if meta[:format] do + {{:__block__, [format: meta[:format]], [key]}, {:__block__, [], [value]}} + else + {{:__block__, [], [key]}, {:__block__, [], [value]}} + end + + _ -> + {key, value} + end + {:ok, - prepend_to_list( + append_to_list( zipper, - {{:__block__, [format: :keyword], [key]}, {:__block__, [], [value]}} + to_append )} {:ok, zipper} -> @@ -216,7 +248,7 @@ defmodule Igniter.Common do value = mappify(rest, value) {:ok, - prepend_to_list( + append_to_list( zipper, {{:__block__, [format: format], [key]}, {:__block__, [], [value]}} )} @@ -261,7 +293,7 @@ defmodule Igniter.Common do format = map_keys_format(zipper) {:ok, - prepend_to_list( + append_to_list( zipper, {{:__block__, [format: format], [key]}, {:__block__, [], [value]}} )} @@ -372,7 +404,7 @@ defmodule Igniter.Common do |> nth_right(index) |> case do :error -> - nil + :error {:ok, nth} -> {:ok, func.(nth)} @@ -385,7 +417,7 @@ defmodule Igniter.Common do |> Zipper.down() |> case do nil -> - nil + :error zipper -> zipper @@ -401,6 +433,67 @@ defmodule Igniter.Common do end end + def move_to_nth_argument(zipper, index) do + if pipeline?(zipper) do + if index == 0 do + zipper + |> Zipper.down() + |> case do + nil -> + :error + + zipper -> + {:ok, zipper} + end + else + zipper + |> Zipper.down() + |> case do + nil -> + :error + + zipper -> + zipper + |> Zipper.rightmost() + |> Zipper.down() + |> case do + nil -> + :error + + zipper -> + zipper + |> nth_right(index) + |> case do + :error -> + :error + + {:ok, nth} -> + {:ok, nth} + end + end + end + end + else + zipper + |> Zipper.down() + |> case do + nil -> + :error + + zipper -> + zipper + |> nth_right(index) + |> case do + :error -> + :error + + {:ok, nth} -> + {:ok, nth} + end + end + end + end + def argument_matches_predicate?(zipper, index, func) do if pipeline?(zipper) do if index == 0 do @@ -478,6 +571,21 @@ defmodule Igniter.Common do end # sobelow_skip ["DOS.StringToAtom"] + def move_to_module_using(zipper, [module]) do + move_to_module_using(zipper, module) + end + + def move_to_module_using(zipper, [module | rest] = one_of_modules) + when is_list(one_of_modules) do + case move_to_module_using(zipper, module) do + {:ok, zipper} -> + {:ok, zipper} + + :error -> + move_to_module_using(zipper, rest) + end + end + def move_to_module_using(zipper, module) do split_module = module @@ -617,6 +725,12 @@ defmodule Igniter.Common do |> Zipper.insert_child(quoted) end + def append_to_list(zipper, quoted) do + zipper + |> maybe_move_to_block() + |> Zipper.append_child(quoted) + end + def remove_index(zipper, index) do zipper |> maybe_move_to_block() @@ -760,7 +874,7 @@ defmodule Igniter.Common do end def keywordify([key | rest], value) do - [{key, keywordify(rest, value)}] + [{{:__block__, [format: :keyword], [key]}, {:__block__, [], [keywordify(rest, value)]}}] end @doc false diff --git a/lib/deps.ex b/lib/deps.ex index 2c585f6..f49988f 100644 --- a/lib/deps.ex +++ b/lib/deps.ex @@ -4,31 +4,36 @@ defmodule Igniter.Deps do alias Igniter.Common alias Sourceror.Zipper - def add_dependency(igniter, name, version) do - case get_dependency_declaration(igniter, name) do - nil -> - do_add_dependency(igniter, name, version) - - current -> - desired = "`{#{inspect(name)}, #{inspect(version)}}`" - current = "`#{current}`" + def add_dependency(igniter, name, version, yes? \\ false) do + if name in List.wrap(igniter.assigns[:manually_installed]) do + igniter + else + case get_dependency_declaration(igniter, name) do + nil -> + do_add_dependency(igniter, name, version) - if desired == current do - igniter - else - if Mix.shell().yes?(""" - Dependency #{name} is already in mix.exs. Should we replace it? + current -> + desired = "`{#{inspect(name)}, #{inspect(version)}}`" + current = "`#{current}`" - Desired: #{desired} - Found: #{current} - """) do + if desired == current do igniter - |> remove_dependency(name) - |> do_add_dependency(name, version) else - igniter + if yes? || + Mix.shell().yes?(""" + Dependency #{name} is already in mix.exs. Should we replace it? + + Desired: #{desired} + Found: #{current} + """) do + igniter + |> remove_dependency(name) + |> do_add_dependency(name, version) + else + igniter + end end - end + end end end diff --git a/lib/igniter.ex b/lib/igniter.ex index e9b8564..7c6e6b8 100644 --- a/lib/igniter.ex +++ b/lib/igniter.ex @@ -3,22 +3,61 @@ defmodule Igniter do Igniter is a library for installing packages and generating code. """ - defstruct [:rewrite, issues: [], tasks: []] + defstruct [:rewrite, issues: [], tasks: [], warnings: [], assigns: %{}] @type t :: %__MODULE__{ rewrite: Rewrite.t(), issues: [String.t()], - tasks: [{String.t() | list(String.t())}] + tasks: [{String.t() | list(String.t())}], + warnings: [String.t()], + assigns: map() } def new do %__MODULE__{rewrite: Rewrite.new()} end + def assign(igniter, key, value) do + %{igniter | assigns: Map.put(igniter.assigns, key, value)} + end + + def assign(igniter, key_vals) do + Enum.reduce(key_vals, igniter, fn {key, value}, igniter -> + assign(igniter, key, value) + end) + end + + def update_glob(igniter, glob, func) do + igniter = + glob + |> Path.wildcard() + |> Enum.reduce(igniter, fn path, igniter -> + if Path.extname(path) != ".ex" do + raise ArgumentError, "Expected a .ex file, got #{inspect(path)}" + end + + Igniter.include_existing_elixir_file(igniter, path) + end) + + Enum.reduce(igniter.rewrite, igniter, fn source, igniter -> + path = Rewrite.Source.get(source, :path) + + if GlobEx.match?(glob, path) do + update_elixir_file(igniter, path, func) + else + igniter + end + end) + end + def add_issue(igniter, issue) do %{igniter | issues: [issue | igniter.issues]} end + def add_warning(igniter, warning) do + %{igniter | issues: [warning | igniter.warnings]} + end + def add_task(igniter, task, argv \\ []) when is_binary(task) do %{igniter | tasks: igniter.tasks ++ [{task, argv}]} end @@ -334,12 +373,18 @@ defmodule Igniter do else igniter = "**/.formatter.exs" - |> Path.relative_to(File.cwd!()) |> Path.wildcard() |> Enum.reduce(igniter, fn path, igniter -> Igniter.include_existing_elixir_file(igniter, path) end) + igniter = + if File.exists?(".formatter.exs") do + Igniter.include_existing_elixir_file(igniter, ".formatter.exs") + else + igniter + end + rewrite = igniter.rewrite formatter_exs_files = @@ -371,7 +416,10 @@ defmodule Igniter do source {:ok, opts} -> - formatted = Rewrite.Source.Ex.format(source, opts) + formatted = + with_evaled_configs(rewrite, fn -> + Rewrite.Source.Ex.format(source, opts) + end) source |> Rewrite.Source.Ex.put_formatter_opts(opts) @@ -386,6 +434,24 @@ defmodule Igniter do end end + # for now we only eval `config.exs` + defp with_evaled_configs(rewrite, fun) do + case Rewrite.source(rewrite, "config/config.exs") do + {:ok, source} -> + content = Rewrite.Source.get(source, :content) + + "config/config.exs" + |> Config.Reader.eval!(content) + |> Application.put_all_env() + + # okay so right now we don't actually reset the config, mostly because I'm not sure it ever actually matters? + fun.() + + _ -> + fun.() + end + end + # sobelow_skip ["RCE.CodeModule"] defp find_formatter_exs_file_options(path, formatter_exs_files) do case Map.fetch(formatter_exs_files, path) do diff --git a/lib/install.ex b/lib/install.ex index 1808d41..bf27c29 100644 --- a/lib/install.ex +++ b/lib/install.ex @@ -15,7 +15,7 @@ defmodule Igniter.Install do # only supports hex installation at the moment def install(install, argv) do - install_list = install_list(install) + install_list = String.split(install, ",") Application.ensure_all_started(:req) @@ -26,29 +26,29 @@ defmodule Igniter.Install do igniter = Igniter.new() - igniter = - Enum.reduce(install_list, igniter, fn install, igniter -> - if local_dep?(install) do - Mix.shell().info( - "Not looking up dependency for #{install}, because a local dependency is detected" - ) - - igniter - else - case Req.get!("https://hex.pm/api/packages/#{install}").body do - %{ - "releases" => [ - %{"version" => version} - | _ - ] - } -> - requirement = Igniter.Version.version_string_to_general_requirement(version) - - Igniter.Deps.add_dependency(igniter, install, requirement) - - _ -> - Igniter.add_issue(igniter, "No published versions of #{install} on hex") - end + {igniter, install_list} = + install_list + |> Enum.reduce({igniter, []}, fn install, {igniter, install_list} -> + case determine_dep_type_and_version(install) do + {install, requirement} -> + install = String.to_atom(install) + + if local_dep?(install) do + Mix.shell().info( + "Not looking up dependency for #{install}, because a local dependency is detected" + ) + + {igniter, [install | install_list]} + else + {Igniter.Deps.add_dependency(igniter, install, requirement, "--yes" in argv), + [install | install_list]} + end + + :error -> + {Igniter.add_issue( + igniter, + "Could not determine source for requested package #{install}" + ), install_list} end end) @@ -101,6 +101,10 @@ defmodule Igniter.Install do all_tasks = Enum.filter(Mix.Task.load_all(), &implements_behaviour?(&1, Igniter.Mix.Task)) + igniter = + Igniter.new() + |> Igniter.assign(%{manually_installed: install_list}) + install_list |> Enum.flat_map(fn install -> all_tasks @@ -109,7 +113,7 @@ defmodule Igniter.Install do end) |> List.wrap() end) - |> Enum.reduce(Igniter.new(), fn task, igniter -> + |> Enum.reduce(igniter, fn task, igniter -> Igniter.compose_task(igniter, task, argv) end) |> Igniter.do_or_dry_run(argv) @@ -146,15 +150,93 @@ defmodule Igniter.Install do false end - # sobelow_skip ["DOS.StringToAtom"] - defp install_list(install) do - install - |> String.split(",") - |> Enum.map(&String.to_atom/1) - end - defp local_dep?(install) do config = Mix.Project.config()[:deps][install] Keyword.keyword?(config) && config[:path] end + + defp determine_dep_type_and_version(requirement) do + case String.split(requirement, "@", trim: true) do + [package] -> + if Regex.match?(~r/^[a-z][a-z0-9_]*$/, package) do + case Req.get!("https://hex.pm/api/packages/#{package}").body do + %{ + "releases" => [ + %{"version" => version} + | _ + ] + } -> + {package, Igniter.Version.version_string_to_general_requirement!(version)} + + _ -> + :error + end + else + :error + end + + [package, version] -> + case version do + "git:" <> requirement -> + if String.contains?(requirement, "@") do + case String.split(requirement, ["@"], trim: true) do + [url, ref] -> + [git: url, ref: ref] + + _ -> + :error + end + else + [git: requirement] + end + + "github:" <> requirement -> + if String.contains?(requirement, "@") do + case String.split(requirement, ["/", "@"], trim: true) do + [org, project, ref] -> + [github: "#{org}/#{project}", ref: ref] + + _ -> + :error + end + else + [github: requirement] + end + + "local:" <> requirement -> + [path: requirement] + + "~>" <> version -> + "~> #{version}" + + "==" <> version -> + "== #{version}" + + ">=" <> version -> + ">= #{version}" + + version -> + case Version.parse(version) do + {:ok, version} -> + "== #{version}" + + _ -> + case Igniter.Version.version_string_to_general_requirement(version) do + {:ok, requirement} -> + requirement + + _ -> + :error + end + end + end + |> case do + :error -> + :error + + requirement -> + {package, requirement} + end + end + end end diff --git a/lib/version.ex b/lib/version.ex index 1db84a5..29012d1 100644 --- a/lib/version.ex +++ b/lib/version.ex @@ -1,13 +1,32 @@ defmodule Igniter.Version do + def version_string_to_general_requirement!(version) do + case version_string_to_general_requirement(version) do + {:ok, requirement} -> requirement + {:error, error} -> raise ArgumentError, error + end + end + def version_string_to_general_requirement(version) do version - |> Version.parse!() + |> pad_zeroes() + |> Version.parse() |> case do - %Version{major: 0, minor: minor} -> - "~> 0.#{minor}" + {:ok, %Version{major: 0, minor: minor}} -> + {:ok, "~> 0.#{minor}"} + + {:ok, %Version{major: major}} -> + {:ok, "~> #{major}.0"} + + :error -> + {:error, "invalid version string"} + end + end - %Version{major: major} -> - "~> #{major}.0" + defp pad_zeroes(version) do + case String.split(version, ".", trim: true) do + [_major, _minor] -> version <> ".0" + [_major] -> version <> ".0.0" + _ -> version end end end diff --git a/mix.exs b/mix.exs index a102617..3ca45f1 100644 --- a/mix.exs +++ b/mix.exs @@ -55,6 +55,7 @@ defmodule Igniter.MixProject do [ {:rewrite, "~> 0.9"}, {:req, "~> 0.4"}, + {:glob_ex, "~> 0.1.7"}, # Dev/Test dependencies {:eflame, "~> 1.0", only: [:dev, :test]}, {:ex_doc, "~> 0.32", only: [:dev, :test], runtime: false}, diff --git a/test/config_test.exs b/test/config_test.exs index 76c3ef6..163d1ae 100644 --- a/test/config_test.exs +++ b/test/config_test.exs @@ -29,7 +29,7 @@ defmodule Igniter.ConfigTest do config_file = Rewrite.source!(rewrite, "config/fake.exs") assert Source.get(config_file, :content) == """ - config :fake, [[foo: [bar: "baz"]], buz: [:blat]] + config :fake, foo: [bar: "baz"], buz: [:blat] """ end