1defmodule Hex.Resolver.Backtracks do
2  @moduledoc false
3
4  require Record
5
6  Record.defrecordp(:parent, [:name, :repo, :version, :requirement, :repo_requirement])
7
8  @ets :hex_backtracks
9
10  def start() do
11    :ets.new(@ets, [])
12  end
13
14  def stop(ets) do
15    :ets.delete(ets)
16  end
17
18  def add(ets, repo, name, version, parents) do
19    parents = order_parents(parents)
20
21    case :ets.lookup(ets, {repo, name, parents}) do
22      [{_, versions}] ->
23        unless version in versions do
24          :ets.insert(ets, {{repo, name, parents}, [version | versions]})
25        end
26
27      [] ->
28        :ets.insert(ets, {{repo, name, parents}, [version]})
29    end
30  end
31
32  def collect(ets) do
33    :ets.tab2list(ets)
34    |> normalize()
35    |> merge_versions()
36    |> expand_merged()
37    |> sort_backtracks()
38  end
39
40  defp order_parents(parents) do
41    parents
42    |> Enum.uniq()
43    |> Enum.sort()
44  end
45
46  defp normalize(backtracks) do
47    # Expand versions into individual backtracks again
48    Enum.flat_map(backtracks, fn
49      {{repo, name, parents}, []} ->
50        [{{repo, name}, nil, parents}]
51
52      {{repo, name, parents}, versions} ->
53        Enum.map(versions, &{{repo, name}, &1, parents})
54    end)
55  end
56
57  defp sort_backtracks(backtracks) do
58    Enum.map(backtracks, fn {repo, name, versions, parents} ->
59      # If nil is in versions it means any version of the dependency
60      # will fail with the given parents. nil is from when the resolver
61      # failed to activate ANY version of the dependency.
62      versions = if nil in versions, do: [], else: Enum.sort(versions, &version_cmp/2)
63      parents = sort_parents(parents)
64      {name, versions, repo, parents}
65    end)
66    |> Enum.sort()
67  end
68
69  defp sort_parents(parents) do
70    Enum.map(parents, fn parent(version: versions) = parent ->
71      versions =
72        versions
73        |> List.wrap()
74        |> Enum.reject(&is_nil/1)
75        |> Enum.uniq()
76        |> Enum.sort(&version_cmp/2)
77
78      parent(parent, version: versions)
79    end)
80    |> Enum.sort()
81  end
82
83  defp expand_merged(merged) do
84    Enum.flat_map(merged, fn {{repo, name}, list} ->
85      Enum.map(list, fn {versions, parents} ->
86        {repo, name, versions, parents}
87      end)
88    end)
89  end
90
91  defp merge_versions(backtracks) do
92    # For each package try to merge the similar backtrack messages
93    # from conflicts in the resolver.
94    # Complete duplicates are skipped when inserting into the ets table.
95    # If two backtracks have similar parents (only differing in the
96    # parents versions) we merge them. Finally if a package has the exact
97    # same conflicts for two different versions we also merge those.
98    Enum.reduce(backtracks, %{}, fn {name, version, parents}, map ->
99      parents = Enum.sort(parents)
100
101      case Map.fetch(map, name) do
102        {:ok, list} ->
103          case merge_package_version(list, version, parents) do
104            {:ok, list} ->
105              Map.put(map, name, list)
106
107            :error ->
108              Map.put(map, name, [{[version], parents} | list])
109          end
110
111        :error ->
112          Map.put(map, name, [{[version], parents}])
113      end
114    end)
115  end
116
117  # For a given a package try to merge this version and parents
118  # into the existing ones
119  defp merge_package_version(list, version, parents) do
120    update_one(list, [], fn {versions, existing_parents} ->
121      case merge_parents(existing_parents, parents, []) do
122        {:ok, parents} ->
123          {:ok, {insert_new(versions, version), parents}}
124
125        :error ->
126          :error
127      end
128    end)
129  end
130
131  # If two lists of parents match (in package name and requirement)
132  # merge them and append the versions
133  defp merge_parents([], [], acc), do: {:ok, Enum.reverse(acc)}
134
135  defp merge_parents(
136         [parent(repo: repo, name: name, requirement: req, version: versions) | existing],
137         [parent(repo: repo, name: name, requirement: req, version: version) | new],
138         acc
139       ) do
140    versions = [version | List.wrap(versions)]
141    parent = parent(repo: repo, name: name, requirement: req, version: versions)
142    merge_parents(existing, new, [parent | acc])
143  end
144
145  defp merge_parents(_, _, _), do: :error
146
147  # Update a single element in a list
148  defp update_one([], _acc, _fun), do: :error
149
150  defp update_one([head | tail], acc, fun) do
151    case fun.(head) do
152      {:ok, new} -> {:ok, [new | acc] ++ tail}
153      :error -> update_one(tail, [head | acc], fun)
154    end
155  end
156
157  defp insert_new(list, elem) do
158    if elem in list, do: list, else: [elem | list]
159  end
160
161  def unzip(list), do: do_unzip(list, [])
162
163  def do_unzip([head | tail], acc) do
164    acc = inner_unzip(head, acc)
165    do_unzip(tail, acc)
166  end
167
168  def do_unzip([], acc), do: acc
169
170  defp inner_unzip([x | xs], [y | ys]), do: [[x | y] | inner_unzip(xs, ys)]
171  defp inner_unzip([x | xs], []), do: [[x] | inner_unzip(xs, [])]
172  defp inner_unzip([], []), do: []
173
174  defp version_cmp(a, b), do: Hex.Version.compare(a, b) != :gt
175
176  def message({name, versions, repo, parents}, registry) do
177    if parent_messages = parent_messages(registry, parents, repo, name, versions) do
178      Hex.Shell.format([
179        [:underline, "Failed to use \"", name, "\""],
180        versions_message(registry, repo, name, versions),
181        [" because", single_parent_message(parents), :reset, "\n"],
182        parent_messages
183      ])
184      |> IO.iodata_to_binary()
185    end
186  end
187
188  defp single_parent_message(parents) when length(parents) < 2 do
189    " there are no packages that matches the requirement"
190  end
191
192  defp single_parent_message(_parents) do
193    ""
194  end
195
196  defp parent_messages(registry, parents, child_repo, child, child_versions) do
197    {mix, parents} = partition_mix(parents)
198
199    parent_reasons =
200      Enum.map(parents, &{&1, parent_reason(registry, &1, child_repo, child, child_versions)})
201
202    mix_reason = {mix, parent_reason(registry, mix, child_repo, child, child_versions)}
203    messages = Enum.map(parent_reasons, &parent_message(&1, registry))
204    all_reasons = [mix_reason | parent_reasons]
205
206    unless all_green?(all_reasons) do
207      messages =
208        if mix do
209          messages ++ [parent_message(mix_reason, registry)]
210        else
211          messages
212        end
213
214      Enum.map(messages, &["  ", &1, "\n"])
215    end
216  end
217
218  defp parent_message(
219         {parent(name: "mix.lock", version: [], requirement: req), {color, pre_failed?}},
220         _registry
221       ) do
222    [
223      [:bright, "mix.lock", :reset, " specifies ", color, requirement(req), :reset],
224      pre_message(pre_failed?)
225    ]
226  end
227
228  defp parent_message(
229         {parent(name: "mix.exs", version: [], requirement: req), {color, pre_failed?}},
230         _registry
231       ) do
232    [
233      [:bright, "mix.exs", :reset, " specifies ", color, requirement(req), :reset],
234      pre_message(pre_failed?)
235    ]
236  end
237
238  defp parent_message(
239         {parent(repo: repo, name: name, version: versions, requirement: req),
240          {color, pre_failed?}},
241         registry
242       ) do
243    [
244      [:bright, name, versions_message(registry, repo, name, versions), :reset],
245      [" requires ", color, requirement(req), :reset],
246      pre_message(pre_failed?)
247    ]
248  end
249
250  defp parent_message({nil, nil}, _registry), do: []
251
252  defp pre_message(true), do: " *"
253  defp pre_message(false), do: ""
254
255  defp all_green?(reasons) do
256    Enum.all?(reasons, fn
257      {nil, nil} -> false
258      {_parent, {color, _pre}} -> color == :green
259    end)
260  end
261
262  defp partition_mix(parents) do
263    map =
264      Enum.into(parents, %{}, fn parent ->
265        {parent(parent, :name), parent}
266      end)
267
268    parents =
269      Enum.reject(parents, fn parent(name: name) ->
270        name in ~w(mix.exs mix.lock)
271      end)
272
273    {map["mix.lock"] || map["mix.exs"], parents}
274  end
275
276  defp parent_reason(_registry, nil, _child_repo, _child, _versions), do: nil
277
278  defp parent_reason(registry, parent, child_repo, child, []) do
279    versions = registry.versions(child_repo, child)
280    parent_reason(registry, parent, child_repo, child, versions)
281  end
282
283  defp parent_reason(_registry, parent(requirement: req), _child_repo, _child, versions) do
284    num_failures = Enum.count(versions, &(not version_match?(&1, req, [])))
285    num_versions = length(versions)
286    pre_failures = Enum.count(versions, &(not version_match?(&1, req, allow_pre: true)))
287    pre_failed? = pre_failures < num_failures
288
289    {parent_color(num_versions, num_failures), pre_failed?}
290  end
291
292  defp parent_color(_versions, 0), do: :green
293  defp parent_color(versions, versions), do: :red
294  defp parent_color(versions, failures) when failures < versions, do: :yellow
295
296  defp version_match?(_version, nil, _opts) do
297    true
298  end
299
300  defp version_match?(version, req, opts) do
301    Hex.Version.match?(version, req, opts)
302  end
303
304  # Try converting lists of versions to a version range if the list is
305  # a complete range of versions of the package.
306  # For example ["0.1.0", "0.1.1", "0.2.0", "0.3.0"] can be converted
307  # to "to 0.1.0 from 0.3.0" if there are no other releases of the package
308  # between 0.1.0 and 0.3.0.
309  defp versions_message(registry, repo, package, versions) do
310    case {versions, merge_versions?(registry, repo, package, versions)} do
311      {[], _} ->
312        ""
313
314      {[x], _} ->
315        [" (version ", to_string(x), ")"]
316
317      {[x, y], _} ->
318        [" (versions ", to_string(x), " and ", to_string(y), ")"]
319
320      {_, true} when length(versions) > 2 ->
321        first = versions |> List.first() |> to_string()
322        last = versions |> List.last() |> to_string()
323        [" (versions ", first, " to ", last, ")"]
324
325      _ ->
326        versions = Enum.map(versions, &to_string/1)
327        [" (versions ", Enum.join(versions, ", "), ")"]
328    end
329  end
330
331  defp merge_versions?(_registry, _repo, _package, []) do
332    false
333  end
334
335  defp merge_versions?(registry, repo, package, versions) do
336    # Filter pre-releases because they were likely not considered during resolution
337    # This can give false-positive merged version ranges, but it's likely fine
338    all_versions = filter_pre_releases(registry.versions(repo, package))
339    versions = filter_pre_releases(versions)
340    sub_range?(all_versions, versions)
341  end
342
343  defp filter_pre_releases(versions) do
344    Enum.filter(versions, &String.contains?(&1, "-"))
345  end
346
347  # Assumes lists are sorted
348  defp sub_range?(outer, inner), do: do_sub_range?(outer, inner, false)
349
350  defp do_sub_range?(_, [], _), do: true
351  defp do_sub_range?([], _, _), do: false
352  defp do_sub_range?([x | outer], [x | inner], _), do: do_sub_range?(outer, inner, true)
353  defp do_sub_range?(_, _, true), do: false
354  defp do_sub_range?([_ | outer], inner, false), do: do_sub_range?(outer, inner, false)
355
356  defp requirement(nil), do: ">= 0.0.0"
357  defp requirement(req), do: req
358end
359