defmodule HexSolver.Incompatibility do
@moduledoc false
import Kernel, except: [to_string: 1]
alias HexSolver.{Constraint, Incompatibility, PackageRange, Term}
alias HexSolver.Constraints.Range
defstruct terms: [], cause: nil
# Causes:
# * {:conflict, incompatibility, cause}
# * :root
# * :dependency
# * :no_versions
# * :package_not_found
def new(terms, cause) do
terms =
if length(terms) != 1 and match?({:conflict, _, _}, cause) and
Enum.any?(terms, &(&1.positive or &1.package_range.name == "$root")) do
Enum.filter(terms, &(not &1.positive or &1.package_range.name != "$root"))
else
terms
end
case terms do
[_first] ->
%Incompatibility{terms: terms, cause: cause}
[%{package_range: %{name: first}}, %{package_range: %{name: second}}]
when first != second ->
%Incompatibility{terms: terms, cause: cause}
_ ->
terms =
Enum.reduce(terms, %{}, fn term, map ->
Map.update(map, term.package_range.name, term, &Term.intersect(&1, term))
end)
%Incompatibility{terms: Map.values(terms), cause: cause}
end
end
def failure?(%Incompatibility{terms: []}), do: true
def failure?(%Incompatibility{terms: [%{package_range: %{name: "$root"}}]}), do: true
def failure?(%Incompatibility{}), do: false
def to_string(incompatibilty, opts \\ [])
def to_string(
%Incompatibility{
cause: :dependency,
terms: [
%Term{package_range: %PackageRange{name: "$lock"}, positive: true},
%Term{positive: false} = dependee
]
},
opts
) do
"\"the lock\" specifies #{bright_term_abs(dependee, opts)}"
end
def to_string(
%Incompatibility{
cause: :dependency,
terms: [%Term{positive: true} = depender, %Term{positive: false} = dependee]
},
opts
) do
"#{terse_every(depender, opts)} depends on #{bright_term_abs(dependee, opts)}"
end
def to_string(%Incompatibility{cause: :no_versions, terms: terms}, opts) do
[%Term{positive: true} = term] = terms
"no versions of #{package_name(term, opts)} match #{bright(term.package_range.constraint, opts)}"
end
def to_string(%Incompatibility{cause: :package_not_found, terms: terms}, opts) do
[%Term{positive: true} = term] = terms
"#{package_name(term, opts)} doesn't exist"
end
def to_string(%Incompatibility{cause: :root, terms: terms}, opts) do
[%Term{positive: false} = term] = terms
"#{package_name(term, opts)} is #{bright(term.package_range.constraint, opts)}"
end
def to_string(%Incompatibility{terms: []}, _opts) do
"version solving failed"
end
def to_string(
%Incompatibility{terms: [%Term{package_range: %PackageRange{name: "$root"}}]},
_opts
) do
"version solving failed"
end
def to_string(
%Incompatibility{
terms: [
%Term{
positive: true,
package_range: %PackageRange{constraint: %Range{min: nil, max: nil}}
} = term
]
},
opts
) do
"no version of #{package_name(term, opts)} is allowed"
end
def to_string(%Incompatibility{terms: [%Term{positive: true} = term]}, opts) do
"#{terse_name(term, opts)} is forbidden"
end
def to_string(%Incompatibility{terms: [%Term{positive: false} = term]}, opts) do
"#{terse_name(%Term{term | positive: true}, opts)} is required"
end
def to_string(
%Incompatibility{terms: [%{positive: true} = left, %{positive: true} = right]},
opts
) do
"#{terse_name(left, opts)} is incompatible with #{terse_name(right, opts)}"
end
def to_string(
%Incompatibility{terms: [%{positive: false} = left, %{positive: false} = right]},
opts
) do
"either #{bright_term_abs(left, opts)} or #{bright_term_abs(right, opts)}"
end
def to_string(%Incompatibility{terms: terms}, opts) do
{positive, negative} = Enum.split_with(terms, & &1.positive)
cond do
positive != [] and negative != [] ->
case positive do
[term] ->
"#{bright_term_abs(term, opts)} requires #{Enum.map_join(negative, " or ", &bright_term_abs(&1, opts))}"
_ ->
"if #{Enum.map_join(positive, " and ", &bright_term_abs(&1, opts))} then #{Enum.map_join(negative, " or ", &bright_term_abs(&1, opts))}"
end
positive != [] ->
"one of #{Enum.map_join(positive, " or ", &bright_term_abs(&1, opts))} must be false"
negative != [] ->
"one of #{Enum.map_join(negative, " or ", &bright_term_abs(&1, opts))} must be true"
end
end
def to_string_and(left, right, opts \\ [])
def to_string_and(
%Incompatibility{
terms: [%{package_range: %{name: "$lock"}}, _dependency],
cause: :dependency
} = left,
%Incompatibility{
terms: [%{package_range: %{name: "$root"}}, %{package_range: %{name: "$lock"}}],
cause: :dependency
},
opts
) do
to_string(left, opts)
end
def to_string_and(%Incompatibility{} = left, %Incompatibility{} = right, opts) do
cond do
requires_both = try_requires_both(left, right, opts) ->
requires_both
requires_through = try_requires_through(left, right, opts) ->
requires_through
requires_forbidden = try_requires_forbidden(left, right, opts) ->
requires_forbidden
true ->
[
to_string(left, opts),
maybe_line(opts[:left_line]),
" and ",
to_string(right, opts),
maybe_line(opts[:right_line])
]
end
|> IO.chardata_to_string()
end
defp try_requires_both(left, right, opts) do
if length(left.terms) == 1 or length(right.terms) == 1 do
throw({__MODULE__, :try_requires_both})
end
left_positive = single_term(left, & &1.positive)
right_positive = single_term(right, & &1.positive)
if !left_positive || !right_positive ||
left_positive.package_range != right_positive.package_range do
throw({__MODULE__, :try_requires_both})
end
left_negatives =
left.terms
|> Enum.reject(& &1.positive)
|> Enum.map_join(" or ", &bright_term_abs(&1, opts))
right_negatives =
right.terms
|> Enum.reject(& &1.positive)
|> Enum.map_join(" or ", &bright_term_abs(&1, opts))
dependency? = left.cause == :dependency and right.cause == :dependency
[
terse_every(left_positive, opts),
" ",
cause_verb(dependency?),
" both ",
left_negatives,
maybe_line(opts[:left_line]),
" and ",
right_negatives,
maybe_line(opts[:right_line])
]
catch
{__MODULE__, :try_requires_both} -> nil
end
defp try_requires_through(right, left, opts) do
if length(left.terms) == 1 or length(right.terms) == 1 do
throw({__MODULE__, :try_requires_through})
end
left_negative = single_term(left, &(not &1.positive))
right_negative = single_term(right, &(not &1.positive))
left_positive = single_term(left, & &1.positive)
right_positive = single_term(right, & &1.positive)
if !left_negative && !right_negative do
throw({__MODULE__, :try_requires_through})
end
{prior, prior_negative, prior_line, latter, latter_line} =
cond do
left_negative && right_positive &&
left_negative.package_range.name == right_positive.package_range.name &&
Term.satisfies?(Term.inverse(left_negative), right_positive) ->
{left, left_negative, opts[:left_line], right, opts[:right_line]}
right_negative && left_positive &&
right_negative.package_range.name == left_positive.package_range.name &&
Term.satisfies?(Term.inverse(right_negative), left_positive) ->
{right, right_negative, opts[:right_line], left, opts[:left_line]}
true ->
throw({__MODULE__, :try_requires_through})
end
prior_positives = Enum.filter(prior.terms, & &1.positive)
buffer =
if length(prior_positives) > 1 do
prior_string = Enum.map_join(prior_positives, " or ", &bright_term_abs(&1, opts))
"if #{prior_string} then "
else
"#{terse_every(List.first(prior_positives), opts)} #{cause_verb(prior)} "
end
buffer = [
buffer,
bright_term_abs(prior_negative, opts),
maybe_line(prior_line),
" which ",
cause_verb(latter)
]
latter_string =
latter.terms
|> Enum.reject(& &1.positive)
|> Enum.map_join(" or ", &bright_term_abs(&1, opts))
[buffer, " ", latter_string, maybe_line(latter_line)]
catch
{__MODULE__, :try_requires_through} -> nil
end
defp try_requires_forbidden(left, right, opts) do
if length(left.terms) != 1 and length(right.terms) != 1 do
throw({__MODULE__, :try_requires_forbidden})
end
{prior, prior_line, latter, latter_line} =
if length(left.terms) == 1 do
{right, opts[:right_line], left, opts[:left_line]}
else
{left, opts[:left_line], right, opts[:right_line]}
end
negative = single_term(prior, &(not &1.positive))
unless negative do
throw({__MODULE__, :try_requires_forbidden})
end
unless Term.satisfies?(Term.inverse(negative), List.first(latter.terms)) do
throw({__MODULE__, :try_requires_forbidden})
end
positives = Enum.filter(prior.terms, & &1.positive)
buffer =
case positives do
[positive] ->
[terse_every(positive, opts), " ", cause_verb(prior), " "]
_ ->
["if ", Enum.map_join(positives, " or ", &bright_term_abs(&1, opts)), " then "]
end
buffer = [
buffer,
bright_term_abs(List.first(latter.terms), opts),
maybe_line(prior_line),
" "
]
buffer =
case latter.cause do
:no_versions -> [buffer, "which doesn't match any versions"]
:package_not_found -> [buffer, "which doesn't exist"]
_ -> [buffer, "which is forbidden"]
end
[buffer, maybe_line(latter_line)]
catch
{__MODULE__, :try_requires_forbidden} -> nil
end
defp bright(string, opts) do
if Keyword.get(opts, :ansi, false) do
[IO.ANSI.bright(), Kernel.to_string(string), IO.ANSI.reset()]
else
~s("#{string}")
end
end
defp cause_verb(true), do: "depends on"
defp cause_verb(false), do: "requires"
defp cause_verb(%Incompatibility{cause: :dependency}), do: "depends on"
defp cause_verb(%Incompatibility{cause: _}), do: "requires"
defp maybe_line(nil), do: ""
defp maybe_line(line), do: " (#{line})"
defp single_term(%Incompatibility{terms: terms}, fun) do
Enum.reduce_while(terms, nil, fn term, found ->
if fun.(term) do
if found do
{:halt, nil}
else
{:cont, term}
end
else
{:cont, found}
end
end)
end
defp package_name(%Term{package_range: %PackageRange{name: "$root"}}, opts),
do: bright("your app", opts)
defp package_name(%Term{package_range: %PackageRange{name: "$lock"}}, opts),
do: bright("the lock", opts)
defp package_name(%Term{package_range: %PackageRange{repo: nil, name: name}}, opts),
do: bright(name, opts)
defp package_name(%Term{package_range: %PackageRange{repo: repo, name: name}}, opts),
do: bright("#{repo}/#{name}", opts)
defp terse_name(term, opts) do
if Constraint.any?(term.package_range.constraint) do
package_name(term, opts)
else
bright(PackageRange.to_string(term.package_range), opts)
end
end
defp terse_every(%Term{package_range: %PackageRange{name: "$root"}}, opts),
do: bright("your app", opts)
defp terse_every(%Term{package_range: %PackageRange{name: "$lock"}}, opts),
do: bright("the lock", opts)
defp terse_every(term, opts) do
if Constraint.any?(term.package_range.constraint) do
"every version of #{package_name(term, opts)}"
else
bright(PackageRange.to_string(term.package_range), opts)
end
end
defp term_abs(term), do: %Term{term | positive: true}
defp bright_term_abs(term, opts), do: bright(term_abs(term), opts)
defimpl String.Chars do
defdelegate to_string(incompatibility), to: HexSolver.Incompatibility
end
defimpl Inspect do
def inspect(%{terms: terms, cause: cause}, _opts) do
"#Incompatibility<#{Enum.map_join(terms, ", ", &Kernel.inspect/1)}#{maybe(", cause: ", cause)}>"
end
defp maybe(_prefix, nil), do: ""
defp maybe(prefix, value), do: "#{prefix}#{inspect(value)}"
end
end