From 7304f221f8dd0572555ff90e18e18c116fd2864f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 14 Oct 2026 14:59:55 +0200 Subject: [PATCH 1/5] Index root dependencies with sub dependencies PackageLister looked up root dependencies with sub dependencies by scanning the list of root dependencies for every version and dependency query, most of which don't match any. Resolving hexpm's dependencies made 13,720 such lookups over 126 root dependencies. Index them by repo and package when the lister is created, keeping the first declaration like Enum.find/2 did. --- lib/hex_solver/package_lister.ex | 24 ++++++++++++++++++++++-- lib/hex_solver/solver.ex | 7 +------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/hex_solver/package_lister.ex b/lib/hex_solver/package_lister.ex index 4a76abd..85b2b77 100644 --- a/lib/hex_solver/package_lister.ex +++ b/lib/hex_solver/package_lister.ex @@ -6,11 +6,22 @@ defmodule HexSolver.PackageLister do defstruct registry: nil, root_dependencies: [], + root_parents: %{}, locked: [], overrides: [], already_prefetched: MapSet.new([{nil, "$lock"}]), already_returned: %{} + def new(registry, root_dependencies, locked, overrides) do + %PackageLister{ + registry: registry, + root_dependencies: root_dependencies, + root_parents: root_parents(root_dependencies), + locked: locked, + overrides: overrides + } + end + # Prefer packages with few remaining versions so that if there is conflict # later it will be forced quickly def pick_package(lister, package_ranges) do @@ -261,7 +272,16 @@ defmodule HexSolver.PackageLister do end) end - defp find_root_dependency(%PackageLister{root_dependencies: dependencies}, repo, package) do - Enum.find(dependencies, &(&1.repo == repo and &1.name == package and &1.dependencies != [])) + # Root dependencies with sub dependencies stand in for registry packages. + # Index them by package, keeping the first declaration of each. + defp root_parents(dependencies) do + Enum.reduce(dependencies, %{}, fn + %{dependencies: []}, parents -> parents + dependency, parents -> Map.put_new(parents, {dependency.repo, dependency.name}, dependency) + end) + end + + defp find_root_dependency(%PackageLister{root_parents: parents}, repo, package) do + Map.get(parents, {repo, package}) end end diff --git a/lib/hex_solver/solver.ex b/lib/hex_solver/solver.ex index 0845cce..d60d65b 100644 --- a/lib/hex_solver/solver.ex +++ b/lib/hex_solver/solver.ex @@ -361,12 +361,7 @@ defmodule HexSolver.Solver do %{repo: repo, name: package, constraint: version, label: label} end) - lister = %PackageLister{ - registry: registry, - root_dependencies: root_dependencies, - locked: locked, - overrides: overrides - } + lister = PackageLister.new(registry, root_dependencies, locked, overrides) %{ solution: %PartialSolution{}, From 051deceab9b55547273627bb7e3fdc5987a34a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 14 Oct 2026 15:01:03 +0200 Subject: [PATCH 2/5] Fetch each package's versions once per solve PackageLister asked the registry for a package's versions every time it considered the package, which pick_package/2 does for every unsatisfied package on every decision. Resolving hexpm's dependencies through Hex made over 10,000 such calls for 155 packages, each through Hex's registry process. Keep the result in the lister for the rest of the solve. This also means the solver works with one list of versions per package even if the registry filters versions by time. --- lib/hex_solver/package_lister.ex | 66 ++++++++++++++++--------- lib/hex_solver/solver.ex | 10 ++-- test/hex_solver/package_lister_test.exs | 57 ++++++++++++++++++++- 3 files changed, 105 insertions(+), 28 deletions(-) diff --git a/lib/hex_solver/package_lister.ex b/lib/hex_solver/package_lister.ex index 85b2b77..24aa2c8 100644 --- a/lib/hex_solver/package_lister.ex +++ b/lib/hex_solver/package_lister.ex @@ -10,7 +10,8 @@ defmodule HexSolver.PackageLister do locked: [], overrides: [], already_prefetched: MapSet.new([{nil, "$lock"}]), - already_returned: %{} + already_returned: %{}, + versions: %{} def new(registry, root_dependencies, locked, overrides) do %PackageLister{ @@ -25,29 +26,33 @@ defmodule HexSolver.PackageLister do # Prefer packages with few remaining versions so that if there is conflict # later it will be forced quickly def pick_package(lister, package_ranges) do - {package_range, versions} = - package_ranges - |> Enum.sort_by(& &1.name) - |> Enum.map(fn package_range -> - case versions(lister, package_range.repo, package_range.name) do - {:ok, versions} -> - allowed = Enum.filter(versions, &Constraint.allows?(package_range.constraint, &1)) - {package_range, allowed} - - :error -> - throw({__MODULE__, :minimal_versions, package_range}) - end - end) - |> Enum.min_by(fn {_package_range, versions} -> length(versions) end) + pick_package(lister, Enum.sort_by(package_ranges, & &1.name), nil) + end + + defp pick_package(lister, [package_range | package_ranges], best) do + case versions(lister, package_range.repo, package_range.name) do + {lister, {:ok, versions}} -> + allowed = Enum.filter(versions, &Constraint.allows?(package_range.constraint, &1)) + count = length(allowed) + + best = + if best == nil or count < elem(best, 1), + do: {package_range, count, allowed}, + else: best + + pick_package(lister, package_ranges, best) + + {lister, :error} -> + {:error, lister, package_range} + end + end - {:ok, package_range, List.first(Enum.sort(versions, &Version.prioritize/2))} - catch - :throw, {__MODULE__, :minimal_versions, package_range} -> - {:error, package_range} + defp pick_package(lister, [], {package_range, _count, allowed}) do + {:ok, lister, package_range, List.first(Enum.sort(allowed, &Version.prioritize/2))} end def dependencies_as_incompatibilities(%PackageLister{} = lister, package_repo, package, version) do - {:ok, versions} = versions(lister, package_repo, package) + {lister, {:ok, versions}} = versions(lister, package_repo, package) already_returned = Map.get(lister.already_returned, {package_repo, package}, %{}) # Dependencies of package keyed on package version @@ -203,10 +208,25 @@ defmodule HexSolver.PackageLister do @version_1 Elixir.Version.parse!("1.0.0") - defp versions(_lister, _repo, "$root"), do: {:ok, [@version_1]} - defp versions(_lister, _repo, "$lock"), do: {:ok, [@version_1]} - + # Fetch each package's versions once per solve, so the solver works with the + # same versions throughout even if the registry filters them by time defp versions(%PackageLister{} = lister, repo, package) do + key = {repo, package} + + case lister.versions do + %{^key => result} -> + {lister, result} + + %{} -> + result = uncached_versions(lister, repo, package) + {%{lister | versions: Map.put(lister.versions, key, result)}, result} + end + end + + defp uncached_versions(_lister, _repo, "$root"), do: {:ok, [@version_1]} + defp uncached_versions(_lister, _repo, "$lock"), do: {:ok, [@version_1]} + + defp uncached_versions(%PackageLister{} = lister, repo, package) do root_dependency = find_root_dependency(lister, repo, package) if root_dependency do diff --git a/lib/hex_solver/solver.ex b/lib/hex_solver/solver.ex index d60d65b..66d12a1 100644 --- a/lib/hex_solver/solver.ex +++ b/lib/hex_solver/solver.ex @@ -110,17 +110,18 @@ defmodule HexSolver.Solver do :done else case PackageLister.pick_package(state.lister, unsatisfied) do - {:ok, package_range, nil} -> + {:ok, lister, package_range, nil} -> # If no version satisfies the constraint then add an incompatibility that indicates that + state = %{state | lister: lister} term = %Term{positive: true, package_range: package_range} incompatibility = Incompatibility.new([term], :no_versions) state = add_incompatibility(state, incompatibility) {:choice, package_range.name, state} - {:ok, %PackageRange{} = package_range, version} -> + {:ok, lister, %PackageRange{} = package_range, version} -> {lister, incompatibilities} = PackageLister.dependencies_as_incompatibilities( - state.lister, + lister, package_range.repo, package_range.name, version @@ -154,7 +155,8 @@ defmodule HexSolver.Solver do state = %{state | solution: solution} {:choice, package_range.name, state} - {:error, %PackageRange{} = package_range} -> + {:error, lister, %PackageRange{} = package_range} -> + state = %{state | lister: lister} package_range = %PackageRange{package_range | constraint: Util.any()} term = %Term{positive: true, package_range: package_range} incompatibility = Incompatibility.new([term], :package_not_found) diff --git a/test/hex_solver/package_lister_test.exs b/test/hex_solver/package_lister_test.exs index e44f889..00578ff 100644 --- a/test/hex_solver/package_lister_test.exs +++ b/test/hex_solver/package_lister_test.exs @@ -1,9 +1,64 @@ defmodule HexSolver.PackageListerTest do use HexSolver.Case, async: true - alias HexSolver.PackageLister + alias HexSolver.{PackageLister, PackageRange} alias HexSolver.Registry.Process, as: Registry + defmodule CountingRegistry do + @behaviour HexSolver.Registry + + alias HexSolver.Registry.Process, as: Registry + + def versions(repo, package) do + count({:versions, repo, package}) + Registry.versions(repo, package) + end + + def dependencies(repo, package, version) do + count({:dependencies, repo, package, to_string(version)}) + Registry.dependencies(repo, package, version) + end + + defdelegate prefetch(packages), to: Registry + + def calls(), do: Process.get({__MODULE__, :calls}, %{}) + + defp count(call) do + Process.put({__MODULE__, :calls}, Map.update(calls(), call, 1, &(&1 + 1))) + end + end + + describe "pick_package/2" do + test "fetches the versions of each package once" do + Registry.put("foo", "1.0.0", []) + Registry.put("foo", "1.1.0", []) + Registry.prefetch([{nil, "foo"}]) + + lister = PackageLister.new(CountingRegistry, [], [], []) + + package_range = %PackageRange{ + name: "foo", + constraint: HexSolver.Requirement.to_constraint!("~> 1.0") + } + + assert {:ok, lister, ^package_range, version} = + PackageLister.pick_package(lister, [package_range]) + + assert version == v("1.1.0") + + package_range = %PackageRange{ + name: "foo", + constraint: HexSolver.Requirement.to_constraint!("1.0.0") + } + + assert {:ok, _lister, ^package_range, version} = + PackageLister.pick_package(lister, [package_range]) + + assert version == v("1.0.0") + assert CountingRegistry.calls() == %{{:versions, nil, "foo"} => 1} + end + end + describe "dependencies_as_incompatibilities/4" do test "skips dependencies already returned for the version" do Registry.put("foo", "1.0.0", [{"bar", "~> 1.0"}]) From 5cbdc52e7c05a76a31a6ebaa8ea97fc61976523e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 14 Oct 2026 15:01:22 +0200 Subject: [PATCH 3/5] Cache the candidates of each package range pick_package/2 filters every unsatisfied package's versions by its constraint on every decision, though most packages stay unsatisfied with the same constraint over many decisions. Resolving hexpm's dependencies evaluated 9,878 package ranges and 256,410 version checks in 156 decisions. Cache the number of allowed versions and the preferred one per repo, package and constraint for the solve. Package ranges are still evaluated in name order, so ties and errors pick the same package as before. --- lib/hex_solver/package_lister.ex | 42 +++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/lib/hex_solver/package_lister.ex b/lib/hex_solver/package_lister.ex index 24aa2c8..3831dda 100644 --- a/lib/hex_solver/package_lister.ex +++ b/lib/hex_solver/package_lister.ex @@ -11,7 +11,8 @@ defmodule HexSolver.PackageLister do overrides: [], already_prefetched: MapSet.new([{nil, "$lock"}]), already_returned: %{}, - versions: %{} + versions: %{}, + candidates: %{} def new(registry, root_dependencies, locked, overrides) do %PackageLister{ @@ -30,14 +31,11 @@ defmodule HexSolver.PackageLister do end defp pick_package(lister, [package_range | package_ranges], best) do - case versions(lister, package_range.repo, package_range.name) do - {lister, {:ok, versions}} -> - allowed = Enum.filter(versions, &Constraint.allows?(package_range.constraint, &1)) - count = length(allowed) - + case candidates(lister, package_range) do + {lister, {:ok, count, version}} -> best = if best == nil or count < elem(best, 1), - do: {package_range, count, allowed}, + do: {package_range, count, version}, else: best pick_package(lister, package_ranges, best) @@ -47,8 +45,34 @@ defmodule HexSolver.PackageLister do end end - defp pick_package(lister, [], {package_range, _count, allowed}) do - {:ok, lister, package_range, List.first(Enum.sort(allowed, &Version.prioritize/2))} + defp pick_package(lister, [], {package_range, _count, version}) do + {:ok, lister, package_range, version} + end + + # The number of versions a package range allows and the preferred one. The + # same package ranges stay unsatisfied over many decisions, so cache them. + defp candidates(lister, %PackageRange{repo: repo, name: name, constraint: constraint}) do + key = {repo, name, constraint} + + case lister.candidates do + %{^key => candidates} -> + {lister, candidates} + + %{} -> + {lister, versions} = versions(lister, repo, name) + + candidates = + case versions do + {:ok, versions} -> + allowed = Enum.filter(versions, &Constraint.allows?(constraint, &1)) + {:ok, length(allowed), List.first(Enum.sort(allowed, &Version.prioritize/2))} + + :error -> + :error + end + + {%{lister | candidates: Map.put(lister.candidates, key, candidates)}, candidates} + end end def dependencies_as_incompatibilities(%PackageLister{} = lister, package_repo, package, version) do From 2a24512a1f6f146eb8f97a87d0a7b9aa55f7773b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 14 Oct 2026 15:02:38 +0200 Subject: [PATCH 4/5] Load the dependencies of older versions lazily dependencies_as_incompatibilities/4 loaded the dependencies of every version of a package to find the range of versions around the selected one that depend on each dependency with the same constraint. Resolving hexpm's 153 dependencies through Hex made 3,689 dependency lookups. Walk out from the selected version instead, loading versions only until the constraint changes, and keep loaded dependencies for the solve so revisiting a package doesn't load them again. Versions outside these runs are no longer loaded, so a registry that warns about invalid requirements when loading dependencies, like Hex, only warns about the versions the solver looked at. --- lib/hex_solver/package_lister.ex | 146 ++++++++++++------------ test/hex_solver/package_lister_test.exs | 24 ++++ 2 files changed, 98 insertions(+), 72 deletions(-) diff --git a/lib/hex_solver/package_lister.ex b/lib/hex_solver/package_lister.ex index 3831dda..2290a52 100644 --- a/lib/hex_solver/package_lister.ex +++ b/lib/hex_solver/package_lister.ex @@ -12,7 +12,8 @@ defmodule HexSolver.PackageLister do already_prefetched: MapSet.new([{nil, "$lock"}]), already_returned: %{}, versions: %{}, - candidates: %{} + candidates: %{}, + dependencies: %{} def new(registry, root_dependencies, locked, overrides) do %PackageLister{ @@ -77,17 +78,9 @@ defmodule HexSolver.PackageLister do def dependencies_as_incompatibilities(%PackageLister{} = lister, package_repo, package, version) do {lister, {:ok, versions}} = versions(lister, package_repo, package) + {lister, {:ok, dependencies}} = dependencies(lister, package_repo, package, version) already_returned = Map.get(lister.already_returned, {package_repo, package}, %{}) - # Dependencies of package keyed on package version - versions_dependencies = - Enum.map(versions, fn version -> - {:ok, dependencies} = dependencies(lister, package_repo, package, version) - {version, dependencies} - end) - - {_version, dependencies} = List.keyfind(versions_dependencies, version, 0) - # Dependencies of package version filtered of overrides and already returned dependencies dependencies = dependencies @@ -102,30 +95,31 @@ defmodule HexSolver.PackageLister do end end) - incompatibilities = - Enum.map(dependencies, fn {dependency, - %{ - repo: dependency_repo, - constraint: constraint, - optional: optional - }} -> - version_constraints = - Enum.map(versions_dependencies, fn {version, dependencies} -> - case Map.fetch(dependencies, dependency) do - {:ok, %{constraint: constraint, optional: optional}} -> - {version, {constraint, optional}} - - :error -> - {version, nil} - end - end) - + index = Enum.find_index(versions, &(&1 == version)) + versions = List.to_tuple(versions) + + {incompatibilities, lister} = + Enum.map_reduce(dependencies, lister, fn {dependency, + %{ + repo: dependency_repo, + constraint: constraint, + optional: optional + }}, + lister -> # Find range of versions around the current version for which the # constraint is the same to create an incompatibility based on a # larger set of versions for the parent package. # This optimization let us skip many versions during conflict resolution. - lower = lower_bound(Enum.reverse(version_constraints), version, {constraint, optional}) - upper = upper_bound(version_constraints, version, {constraint, optional}) + run = %{ + repo: package_repo, + package: package, + versions: versions, + dependency: dependency, + constraint: {constraint, optional} + } + + {lister, lower} = lower_bound(lister, run, index - 1, version) + {lister, upper} = upper_bound(lister, run, index + 1) range = %Range{min: lower, max: upper, include_min: !!lower} package_range = %PackageRange{repo: package_repo, name: package, constraint: range} @@ -143,7 +137,7 @@ defmodule HexSolver.PackageLister do optional: optional } - Incompatibility.new([package_term, dependency_term], :dependency) + {Incompatibility.new([package_term, dependency_term], :dependency), lister} end) prefetch = @@ -180,54 +174,48 @@ defmodule HexSolver.PackageLister do {lister, incompatibilities} end - defp lower_bound(versions_dependencies, version, constraint) do - [{version, _} | versions_dependencies] = skip_to_version(versions_dependencies, version) - - skip_to_last_constraint(versions_dependencies, constraint, version) + # The lowest version in the run of versions below the index that depend on + # the dependency with the same constraint, or nil if the run reaches the + # first version + defp lower_bound(lister, _run, -1, _last) do + {lister, nil} end - defp upper_bound(versions_dependencies, version, constraint) do - versions_dependencies = skip_to_version(versions_dependencies, version) - skip_to_after_constraint(versions_dependencies, constraint) - end - - defp skip_to_version([{version, _constraint} | _] = versions_dependencies, version) do - versions_dependencies - end - - defp skip_to_version([_ | versions_dependencies], version) do - skip_to_version(versions_dependencies, version) + defp lower_bound(lister, run, index, last) do + case same_constraint?(lister, run, index) do + {lister, true} -> lower_bound(lister, run, index - 1, elem(run.versions, index)) + {lister, false} -> {lister, last} + end end - defp skip_to_last_constraint( - [{version, constraint} | versions_dependencies], - {_constraint, _optional} = constraint, - _last - ) do - skip_to_last_constraint(versions_dependencies, constraint, version) + # The first version after the run of versions from the index that depend on + # the dependency with the same constraint, or nil if the run reaches the last + # version + defp upper_bound(lister, run, index) when index == tuple_size(run.versions) do + {lister, nil} end - defp skip_to_last_constraint([], _constraint, _last) do - nil + defp upper_bound(lister, run, index) do + case same_constraint?(lister, run, index) do + {lister, true} -> upper_bound(lister, run, index + 1) + {lister, false} -> {lister, elem(run.versions, index)} + end end - defp skip_to_last_constraint(_versions_dependencies, _constraint, last) do - last - end + # Loads the dependencies of the version, so only the versions in a run and + # the one that ends it are loaded + defp same_constraint?(lister, run, index) do + version = elem(run.versions, index) + {lister, {:ok, dependencies}} = dependencies(lister, run.repo, run.package, version) + dependency = run.dependency - defp skip_to_after_constraint( - [{_, constraint}, {version, constraint} | versions_dependencies], - {_constraint, _optional} = constraint - ) do - skip_to_after_constraint([{version, constraint} | versions_dependencies], constraint) - end + case dependencies do + %{^dependency => %{constraint: constraint, optional: optional}} -> + {lister, {constraint, optional} == run.constraint} - defp skip_to_after_constraint([_, {version, _} | _versions_dependencies], _) do - version - end - - defp skip_to_after_constraint(_, _constraint) do - nil + %{} -> + {lister, false} + end end @version_1 Elixir.Version.parse!("1.0.0") @@ -260,7 +248,21 @@ defmodule HexSolver.PackageLister do end end - defp dependencies(%PackageLister{} = lister, _repo, "$root", @version_1) do + # Dependencies are loaded as the solver needs them and kept for the solve + defp dependencies(%PackageLister{} = lister, repo, package, version) do + key = {repo, package, version} + + case lister.dependencies do + %{^key => result} -> + {lister, result} + + %{} -> + result = uncached_dependencies(lister, repo, package, version) + {%{lister | dependencies: Map.put(lister.dependencies, key, result)}, result} + end + end + + defp uncached_dependencies(%PackageLister{} = lister, _repo, "$root", @version_1) do lock_dependency = if lister.locked == [] do [] @@ -276,7 +278,7 @@ defmodule HexSolver.PackageLister do {:ok, dependency_map(lock_dependency ++ root_dependencies)} end - defp dependencies(%PackageLister{locked: locked}, _repo, "$lock", @version_1) do + defp uncached_dependencies(%PackageLister{locked: locked}, _repo, "$lock", @version_1) do {:ok, Map.new(locked, fn dependency -> {dependency.name, @@ -290,7 +292,7 @@ defmodule HexSolver.PackageLister do end)} end - defp dependencies(%PackageLister{} = lister, repo, package, version) do + defp uncached_dependencies(%PackageLister{} = lister, repo, package, version) do root_dependency = find_root_dependency(lister, repo, package) if root_dependency do diff --git a/test/hex_solver/package_lister_test.exs b/test/hex_solver/package_lister_test.exs index 00578ff..acbe8d7 100644 --- a/test/hex_solver/package_lister_test.exs +++ b/test/hex_solver/package_lister_test.exs @@ -78,6 +78,30 @@ defmodule HexSolver.PackageListerTest do PackageLister.dependencies_as_incompatibilities(lister, nil, "foo", v("1.0.0")) end + test "loads the dependencies of versions only as far as the constraint is the same" do + Registry.put("foo", "0.1.0", [{"bar", "~> 0.1"}]) + Registry.put("foo", "1.0.0", [{"bar", "~> 1.0"}]) + Registry.put("foo", "2.0.0", [{"bar", "~> 2.0"}]) + Registry.put("foo", "3.0.0", [{"bar", "~> 2.0"}]) + Registry.put("foo", "4.0.0", [{"bar", "~> 2.0"}]) + Registry.put("foo", "5.0.0", [{"bar", "~> 3.0"}]) + Registry.put("foo", "6.0.0", [{"bar", "~> 3.0"}]) + Registry.prefetch([{nil, "foo"}]) + + lister = PackageLister.new(CountingRegistry, [], [], []) + + assert {_lister, [incompatibility]} = + PackageLister.dependencies_as_incompatibilities(lister, nil, "foo", v("3.0.0")) + + assert to_string(incompatibility) == ~s("foo >= 2.0.0 and < 5.0.0" depends on "bar ~> 2.0") + + loaded = + for {{:dependencies, nil, "foo", version}, 1} <- CountingRegistry.calls(), + do: version + + assert Enum.sort(loaded) == ["1.0.0", "2.0.0", "3.0.0", "4.0.0", "5.0.0"] + end + test "returns dependencies again for a package with the same name in another repo" do Registry.put("foo", "1.0.0", [{"bar", "~> 1.0"}]) Registry.put("repo", "foo", "1.0.0", [{"bar", "~> 1.0"}]) From 020b065b85c28bed3bf3e5009e6bf0a0ba9c5c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 14 Oct 2026 19:21:16 +0200 Subject: [PATCH 5/5] Test candidate caching and tie-breaking in pick_package Cover the package lister returning cached candidates for the same package range, keying them on the repository and constraint, picking the first package by name among those with the fewest allowed versions, and returning the first package by name without versions after other package ranges' candidates are cached. --- test/hex_solver/package_lister_test.exs | 66 +++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/test/hex_solver/package_lister_test.exs b/test/hex_solver/package_lister_test.exs index acbe8d7..75c07ce 100644 --- a/test/hex_solver/package_lister_test.exs +++ b/test/hex_solver/package_lister_test.exs @@ -57,6 +57,64 @@ defmodule HexSolver.PackageListerTest do assert version == v("1.0.0") assert CountingRegistry.calls() == %{{:versions, nil, "foo"} => 1} end + + test "picks the package with the fewest allowed versions, the first by name on a tie" do + for package <- ["foo", "bar", "baz"], version <- ["1.0.0", "1.1.0"] do + Registry.put(package, version, []) + end + + Registry.put("baz", "1.2.0", []) + Registry.prefetch([{nil, "foo"}, {nil, "bar"}, {nil, "baz"}]) + + lister = PackageLister.new(Registry, [], [], []) + foo = package_range("foo", "~> 1.0") + bar = package_range("bar", "~> 1.0") + baz = package_range("baz", "~> 1.0") + + assert {:ok, lister, ^bar, version} = PackageLister.pick_package(lister, [foo, baz, bar]) + assert version == v("1.1.0") + + foo = package_range("foo", "1.0.0") + + assert {:ok, _lister, ^foo, version} = PackageLister.pick_package(lister, [baz, bar, foo]) + assert version == v("1.0.0") + end + + test "reuses the candidates of a package range" do + Registry.put("foo", "1.0.0", []) + Registry.put("foo", "1.1.0", []) + Registry.put("repo", "foo", "2.0.0", []) + Registry.prefetch([{nil, "foo"}, {"repo", "foo"}]) + + lister = PackageLister.new(Registry, [], [], []) + foo = package_range("foo", "~> 1.0") + + assert {:ok, lister, ^foo, version} = PackageLister.pick_package(lister, [foo]) + assert version == v("1.1.0") + assert lister.candidates == %{{nil, "foo", foo.constraint} => {:ok, 2, v("1.1.0")}} + + cached = %{lister | candidates: %{{nil, "foo", foo.constraint} => {:ok, 1, v("1.0.0")}}} + assert {:ok, _lister, ^foo, version} = PackageLister.pick_package(cached, [foo]) + assert version == v("1.0.0") + + repo_foo = package_range("repo", "foo", ">= 0.0.0") + assert {:ok, _lister, ^repo_foo, version} = PackageLister.pick_package(lister, [repo_foo]) + assert version == v("2.0.0") + end + + test "returns the first package by name without versions after cached candidates" do + Registry.put("foo", "1.0.0", []) + Registry.prefetch([{nil, "foo"}, {nil, "bar"}, {nil, "baz"}]) + + lister = PackageLister.new(Registry, [], [], []) + foo = package_range("foo", "~> 1.0") + bar = package_range("bar", "~> 1.0") + baz = package_range("baz", "~> 1.0") + + assert {:ok, lister, ^foo, _version} = PackageLister.pick_package(lister, [foo]) + assert {:error, lister, ^bar} = PackageLister.pick_package(lister, [foo, baz, bar]) + assert {:error, _lister, ^baz} = PackageLister.pick_package(lister, [foo, baz]) + end end describe "dependencies_as_incompatibilities/4" do @@ -117,4 +175,12 @@ defmodule HexSolver.PackageListerTest do PackageLister.dependencies_as_incompatibilities(lister, "repo", "foo", v("1.0.0")) end end + + defp package_range(repo \\ nil, name, requirement) do + %PackageRange{ + repo: repo, + name: name, + constraint: HexSolver.Requirement.to_constraint!(requirement) + } + end end