Current section
Files
Jump to
Current section
Files
build_support/dependency_sources.exs
defmodule DependencySources do
@moduledoc false
@helper_version 2
@source_keys [:path, :github, :hex]
@github_option_keys [:branch, :ref, :tag, :subdir]
def helper_version, do: @helper_version
def deps(repo_root \\ Path.dirname(__DIR__), opts \\ []) do
repo_root = Path.expand(repo_root)
config = load_config!(Path.join(repo_root, "build_support/dependency_sources.config.exs"))
overrides = load_local_overrides(repo_root)
publish? = Keyword.get(opts, :publish?, publish_mode?())
config
|> deps_config()
|> Enum.map(fn {app, dep_config} ->
app = normalize_app!(app)
dep_config = normalize_dep_config!(dep_config)
override = local_override(app, overrides)
source = selected_source!(app, dep_config, override, publish?, repo_root)
dep_tuple(app, dep_config, source, repo_root, override, [])
end)
end
def dep(app, repo_root \\ Path.dirname(__DIR__), extra_opts \\ []) do
repo_root = Path.expand(repo_root)
config = load_config!(Path.join(repo_root, "build_support/dependency_sources.config.exs"))
overrides = load_local_overrides(repo_root)
app = normalize_app!(app)
dep_config = dep_config_for!(app, config)
override = local_override(app, overrides)
source = selected_source!(app, dep_config, override, publish_mode?(), repo_root)
dep_tuple(app, dep_config, source, repo_root, override, extra_opts)
end
defp load_config!(path) do
{config, _binding} = Code.eval_file(path)
unless is_map(config) or Keyword.keyword?(config) do
raise ArgumentError, "dependency source config must evaluate to a map or keyword list"
end
config
end
defp load_local_overrides(repo_root) do
path = Path.join(repo_root, ".dependency_sources.local.exs")
if File.regular?(path) do
{overrides, _binding} = Code.eval_file(path)
Map.new(overrides[:deps] || overrides["deps"] || %{})
else
%{}
end
end
defp deps_config(config) do
deps = config[:deps] || config["deps"] || config
Map.new(deps)
end
defp dep_config_for!(app, config) do
deps =
config
|> deps_config()
|> Map.new(fn {configured_app, dep_config} ->
{normalize_app!(configured_app), normalize_dep_config!(dep_config)}
end)
case Map.fetch(deps, app) do
{:ok, dep_config} -> dep_config
:error -> raise ArgumentError, "dependency source config is missing #{app}"
end
end
defp normalize_app!(app) when is_atom(app), do: app
defp normalize_app!(app) when is_binary(app), do: String.to_atom(app)
defp normalize_dep_config!(config) when is_map(config), do: config
defp normalize_dep_config!(config) when is_list(config), do: Map.new(config)
defp local_override(app, overrides),
do: normalize_dep_config!(overrides[app] || overrides[Atom.to_string(app)] || %{})
defp selected_source!(app, config, override, publish?, repo_root) do
override_source = override[:source] || override["source"]
cond do
override_source ->
normalize_source!(override_source)
publish? ->
source_from_order!(
app,
config,
config[:publish_order] || config["publish_order"] || [:hex],
repo_root
)
true ->
source_from_order!(
app,
config,
config[:default_order] || config["default_order"] || [:path, :github, :hex],
repo_root
)
end
end
defp source_from_order!(app, config, order, repo_root) do
order
|> Enum.map(&normalize_source!/1)
|> Enum.find(fn
:path -> configured_path_available?(config, repo_root)
source -> configured?(config, source)
end)
|> case do
nil -> raise ArgumentError, "no dependency source is available for #{app}"
source -> source
end
end
defp configured_path_available?(config, repo_root) do
case config[:path] || config["path"] do
nil ->
false
path when is_binary(path) ->
usable_sibling_path?(path, repo_root)
paths when is_list(paths) ->
Enum.any?(paths, &usable_sibling_path?(&1, repo_root))
_other ->
false
end
end
# A configured `:path` candidate only counts as a usable sibling
# checkout when (1) it exists on disk and (2) it does not resolve to a
# Mix-managed `deps/` directory.
#
# Without (2), running `mix deps.get` from a fresh clone could
# materialize multiple sibling deps under the parent project's
# `deps/`, and this helper -- invoked from within one of those
# child deps with `repo_root = <parent>/deps/<child>` -- would then
# mistake another Mix-fetched dep for a developer sibling checkout
# and pick `:path`. That produces a divergent source vs. however a
# peer dep already declared the same app, and Mix refuses to
# resolve with the "overriding a child dependency" error.
defp usable_sibling_path?(path, repo_root) do
abs = Path.expand(path, repo_root)
File.exists?(abs) and not under_mix_deps_dir?(repo_root, abs)
end
# When `repo_root` itself sits under a path component named `deps`,
# `mix_deps_ancestor/1` returns the absolute path to that ancestor
# (the parent project's `deps/` directory). A candidate that resolves
# to anything under that same ancestor is therefore another
# Mix-fetched sibling, not a developer workspace checkout.
defp under_mix_deps_dir?(repo_root, abs) do
case mix_deps_ancestor(repo_root) do
nil -> false
deps_dir -> String.starts_with?(abs <> "/", deps_dir <> "/")
end
end
defp mix_deps_ancestor(repo_root) do
segments = repo_root |> Path.expand() |> Path.split()
segments
|> Enum.reverse()
|> Enum.with_index()
|> Enum.find(fn {seg, _idx} -> seg == "deps" end)
|> case do
nil ->
nil
{"deps", reverse_index} ->
forward_index = length(segments) - reverse_index
segments |> Enum.take(forward_index) |> Path.join()
end
end
defp configured?(config, source),
do: not is_nil(config[source] || config[Atom.to_string(source)])
defp dep_tuple(app, config, :path, repo_root, override, extra_opts) do
path = override[:path] || override["path"] || config[:path] || config["path"]
path =
if is_list(path), do: Enum.find(path, &File.exists?(Path.expand(&1, repo_root))), else: path
{app, Keyword.merge([path: path], dep_options(config, extra_opts))}
end
defp dep_tuple(app, config, :github, _repo_root, override, extra_opts) do
github = Map.new(config[:github] || config["github"] || %{})
github = Map.merge(github, Map.drop(override, [:source, "source"]))
repo = github[:repo] || github["repo"]
opts =
github
|> Enum.flat_map(fn
{key, _value} when key in [:repo, "repo"] ->
[]
{key, value} ->
option_key = normalize_option_key(key)
if option_key in @github_option_keys do
[{option_key, value}]
else
[]
end
end)
{app, Keyword.merge([github: repo], Keyword.merge(opts, dep_options(config, extra_opts)))}
end
defp dep_tuple(app, config, :hex, _repo_root, override, extra_opts) do
requirement = override[:hex] || override["hex"] || config[:hex] || config["hex"]
case dep_options(config, extra_opts) do
[] -> {app, requirement}
opts -> {app, requirement, opts}
end
end
defp dep_options(config, extra_opts) do
config
|> Map.get(:opts, config["opts"] || config[:options] || config["options"] || [])
|> keyword_options()
|> Keyword.merge(keyword_options(extra_opts))
end
defp keyword_options(opts) when is_list(opts), do: opts
defp keyword_options(opts) when is_map(opts), do: Map.to_list(opts)
defp keyword_options(_opts), do: []
defp normalize_source!(source) when source in @source_keys, do: source
defp normalize_source!(source) when is_binary(source), do: String.to_existing_atom(source)
defp normalize_option_key(key) when is_atom(key), do: key
defp normalize_option_key(key) when is_binary(key), do: String.to_existing_atom(key)
defp publish_mode? do
System.argv()
|> Enum.join(" ")
|> String.contains?("hex.")
end
end