diff --git a/lib/mix/lib/mix/dep.ex b/lib/mix/lib/mix/dep.ex index 3fcd0e4cb1a..d3d7a7852e2 100644 --- a/lib/mix/lib/mix/dep.ex +++ b/lib/mix/lib/mix/dep.ex @@ -589,4 +589,8 @@ defmodule Mix.Dep do if is_binary(app), do: String.to_atom(app), else: app end) end + + def add(app, opts, mix_exs) do + Mix.Dep.Adder.add(app, opts, mix_exs) + end end diff --git a/lib/mix/lib/mix/dep/adder.ex b/lib/mix/lib/mix/dep/adder.ex new file mode 100644 index 00000000000..3ddbc2118a3 --- /dev/null +++ b/lib/mix/lib/mix/dep/adder.ex @@ -0,0 +1,107 @@ +defmodule Mix.Dep.Adder do + def add(app, opts, mix_exs) do + lines = String.split(mix_exs, "\n") + + check_for_app!(lines, app) + + dep_opts = get_version_str(opts, app) <> get_only_string(opts) <> get_runtime_string(opts) + new_line = "{:#{app}, #{dep_opts}}" + + new_lines = add_dep_line(lines, [], new_line) + + if lines == new_lines, do: Mix.raise("Unable to identify deps function in mix.exs") + + Mix.shell().info([:green, "Added #{app} to mix.exs: #{new_line}"]) + + Enum.join(new_lines, "\n") + end + + defp get_runtime_string(opts) do + if Keyword.has_key?(opts, :runtime) and !opts[:runtime] do + ", runtime: false" + else + "" + end + end + + defp get_only_string(opts) do + case opts[:only] do + [] -> "" + [env] -> ", only: :#{env}" + envs -> ", only: [" <> (envs |> Enum.map(&(":" <> &1)) |> Enum.join(", ")) <> "]" + end + end + + defp get_version_str(opts, app) do + case opts[:path] do + nil -> + case opts[:version] || latest_hex_version(app) do + "~> " <> _ = version -> ~s|"#{version}"| + ">= " <> _ = version -> ~s|"#{version}"| + "0.0.0" -> ~s|">= 0.0.0"| + version -> ~s|"~> #{version}"| + end + + path -> + ~s|path: "#{path}"| + end + end + + defp check_for_app!(lines, app) do + lines + |> Enum.find(fn line -> + line + |> String.trim() + |> String.starts_with?("{:" <> app) + end) + |> case do + nil -> + :ok + + found -> + Mix.raise("#{app} already exists in mix.exs as #{String.trim(found)}") + end + end + + defp latest_hex_version(_app) do + # TODO: Get latest version from Hex + "0.0.0" + end + + defp add_dep_line([line1, line2 | rest], acc, dep_line) do + cond do + String.trim(line1) == "defp deps do" && String.trim(line2) == "[" -> + dep_line = + if additional_deps?(rest) do + dep_line <> "," + else + dep_line + end + + Enum.reverse(acc) ++ [line1, line2, indent(dep_line, line2) | rest] + + String.trim(line1) == "defp deps do" && String.trim(line2) == "[]" -> + Enum.reverse(acc) ++ + [line1, indent("[", line1), indent(dep_line, line2), indent("]", line1) | rest] + + true -> + add_dep_line([line2 | rest], [line1 | acc], dep_line) + end + end + + defp indent(line, parent_line) do + String.duplicate(" ", indented(parent_line) + 2) <> line + end + + defp indented(line, count \\ 0) + defp indented(" " <> rest, count), do: indented(rest, count + 1) + defp indented(_, count), do: count + + defp additional_deps?([head | tail]) do + case String.trim(head) do + "{:" <> _ -> true + "]" -> false + _ -> additional_deps?(tail) + end + end +end diff --git a/lib/mix/lib/mix/tasks/deps.add.ex b/lib/mix/lib/mix/tasks/deps.add.ex new file mode 100644 index 00000000000..0a207f598ce --- /dev/null +++ b/lib/mix/lib/mix/tasks/deps.add.ex @@ -0,0 +1,98 @@ +defmodule Mix.Tasks.Deps.Add do + use Mix.Task + + @shortdoc "Adds the specified dependency" + + @moduledoc """ + Adds the given dependency to mix.exs. + + The given dependency will be added to mix.exs at the given version, or the + latest version available on hex if no version is specified. Alternatively a + path can be specified instead of a version. `runtime: false` and `only: :env` + can also be specified by the below options. + + ## Command line options + + * `--version` - version to add (defaults to latest available on hex) + * `--only` - Adds the only: :env option to the dependency + * `--no-runtime - Adds the runtime: false option to the dependency + * `--path` - Adds the `path: "path"` option to the dependency instead of `version:` + + ## Notes + + --version will automatically add "~> " to the front of the version unless the + version is `0.0.0`, in which case it will use `>= 0.0.0` + + --only` supports multiple values in this format: + + `mix deps.add foo --only test --only dev + + `mix deps.add` requires the standard deps function in your mix.exs file in one of these formats: + + defp deps do + [ + ... + + or + defp deps do + [] + ... + + """ + + @snake_case_regex ~r/^[a-z|_]+$/ + + @impl true + def run(args) do + mix_exs = add(args, File.read!("mix.exs")) + File.write!("mix.exs", mix_exs) + end + + def add(args, mix_exs) do + {opts, rest, invalid} = + OptionParser.parse(args, + strict: [version: :string, only: :keep, runtime: :boolean, path: :string] + ) + + app = + case rest do + [binary] when is_binary(binary) -> + binary + + _ -> + Mix.raise("Invalid options: #{inspect(rest)}") + end + + if invalid != [], do: Mix.raise("Invalid options: #{inspect(invalid)}") + + if opts[:version] && opts[:path], + do: Mix.raise("Cannot specify both version and path") + + app = normalize_atom(app, "package") + + only = + case Keyword.get_values(opts, :only) do + nil -> [] + env when is_binary(env) -> [normalize_atom(env, "only")] + envs when is_list(envs) -> Enum.map(envs, &normalize_atom(&1, "only")) + end + + opts = Keyword.put(opts, :only, only) + + Mix.Dep.add(app, opts, mix_exs) + end + + defp normalize_atom(atom, type) do + atom = + case atom do + ":" <> atom -> atom + atom -> atom + end + + if !Regex.match?(@snake_case_regex, atom) do + Mix.raise("Invalid #{type}: #{inspect(atom)}") + end + + atom + end +end diff --git a/lib/mix/test/mix/tasks/deps_add_test.exs b/lib/mix/test/mix/tasks/deps_add_test.exs new file mode 100644 index 00000000000..626cfce6b7f --- /dev/null +++ b/lib/mix/test/mix/tasks/deps_add_test.exs @@ -0,0 +1,227 @@ +Code.require_file("../../test_helper.exs", __DIR__) + +defmodule Mix.Tasks.Deps.AddTest do + use ExUnit.Case + + alias Mix.Tasks.Deps.Add + + @mix_exs """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:foo, "~> 0.8.1"} + ] + end + end + """ + + @mix_exs_empty_deps """ + defmodule DepsAddTest.MixProject do + defp deps do + [] + end + end + """ + + @mix_exs_default_deps """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end + end + """ + + describe "adding deps with bad values" do + test "dep already exists" do + assert_raise Mix.Error, "foo already exists in mix.exs as {:foo, \"~> 0.8.1\"}", fn -> + Add.add(["foo"], @mix_exs) + end + end + + test "non snake-case dep" do + assert_raise Mix.Error, "Invalid package: \"camelCaseDep\"", fn -> + Add.add(["camelCaseDep"], @mix_exs) + end + end + + test "invalid opt" do + assert_raise Mix.Error, "Invalid options: [{\"--not-real-opt\", nil}]", fn -> + Add.add(["bar", "--not-real-opt"], @mix_exs) + end + end + + test "both version and path" do + assert_raise Mix.Error, "Cannot specify both version and path", fn -> + Add.add(["bar", "--version", "1.0.0", "--path", "../bar"], @mix_exs) + end + end + end + + describe "adding dep" do + test "with existing" do + assert Add.add(["asdf", "--version", "1.0.0"], @mix_exs) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, "~> 1.0.0"}, + {:foo, "~> 0.8.1"} + ] + end + end + """ + + assert_received {:mix_shell, :info, ["Added asdf to mix.exs: {:asdf, \"~> 1.0.0\"}"]} + end + + test "without existing" do + assert Add.add(["asdf", "--version", "1.0.0"], @mix_exs_empty_deps) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, "~> 1.0.0"} + ] + end + end + """ + + assert_received {:mix_shell, :info, ["Added asdf to mix.exs: {:asdf, \"~> 1.0.0\"}"]} + end + + test "with default deps" do + assert Add.add(["asdf", "--version", "1.0.0"], @mix_exs_default_deps) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, "~> 1.0.0"} + # {:dep_from_hexpm, "~> 0.3.0"}, + # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + ] + end + end + """ + + assert_received {:mix_shell, :info, ["Added asdf to mix.exs: {:asdf, \"~> 1.0.0\"}"]} + end + + test "with colon on deps arg" do + assert Add.add([":asdf", "--version", "1.0.0"], @mix_exs) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, "~> 1.0.0"}, + {:foo, "~> 0.8.1"} + ] + end + end + """ + + assert_received {:mix_shell, :info, ["Added asdf to mix.exs: {:asdf, \"~> 1.0.0\"}"]} + end + + test "with 0.0.0" do + assert Add.add(["asdf", "--version", "0.0.0"], @mix_exs) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, ">= 0.0.0"}, + {:foo, "~> 0.8.1"} + ] + end + end + """ + + assert_received {:mix_shell, :info, ["Added asdf to mix.exs: {:asdf, \">= 0.0.0\"}"]} + end + + test "with path" do + assert Add.add([":asdf", "--path", "../asdf_local"], @mix_exs) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, path: "../asdf_local"}, + {:foo, "~> 0.8.1"} + ] + end + end + """ + + assert_received {:mix_shell, :info, + ["Added asdf to mix.exs: {:asdf, path: \"../asdf_local\"}"]} + end + + test "no runtime" do + assert Add.add(["asdf", "--version", "1.0.0", "--no-runtime"], @mix_exs) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, "~> 1.0.0", runtime: false}, + {:foo, "~> 0.8.1"} + ] + end + end + """ + + assert_received {:mix_shell, :info, + ["Added asdf to mix.exs: {:asdf, \"~> 1.0.0\", runtime: false}"]} + end + + test "only test" do + assert Add.add( + ["asdf", "--version", "1.0.0", "--only", "test"], + @mix_exs_empty_deps + ) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, "~> 1.0.0", only: :test} + ] + end + end + """ + + assert_received {:mix_shell, :info, + ["Added asdf to mix.exs: {:asdf, \"~> 1.0.0\", only: :test}"]} + end + + test "only :test" do + assert Add.add( + ["asdf", "--version", "1.0.0", "--only", ":test"], + @mix_exs_empty_deps + ) == """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, "~> 1.0.0", only: :test} + ] + end + end + """ + + assert_received {:mix_shell, :info, + ["Added asdf to mix.exs: {:asdf, \"~> 1.0.0\", only: :test}"]} + end + + test "only test and dev" do + assert Add.add( + ["asdf", "--version", "1.0.0", "--only", "test", "--only", ":dev"], + @mix_exs + ) == + """ + defmodule DepsAddTest.MixProject do + defp deps do + [ + {:asdf, "~> 1.0.0", only: [:test, :dev]}, + {:foo, "~> 0.8.1"} + ] + end + end + """ + + assert_received {:mix_shell, :info, + ["Added asdf to mix.exs: {:asdf, \"~> 1.0.0\", only: [:test, :dev]}"]} + end + end +end