From 291c624cee5ae9343623528e0fc8762eac4dae2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Tue, 4 Aug 2026 19:18:16 +0200 Subject: [PATCH] Serialize constraints as version requirements --- lib/hex_solver.ex | 12 ++++++- lib/hex_solver/constraint.ex | 1 + lib/hex_solver/constraints/empty.ex | 4 +++ lib/hex_solver/constraints/impl.ex | 3 ++ lib/hex_solver/constraints/range.ex | 8 +++++ lib/hex_solver/constraints/union.ex | 4 +++ lib/hex_solver/constraints/version.ex | 4 +++ lib/hex_solver/requirement.ex | 24 ++++++++++++-- test/hex_solver/constraint_test.exs | 9 ++++++ test/hex_solver/constraints/empty_test.exs | 4 +++ test/hex_solver/constraints/range_test.exs | 9 ++++++ test/hex_solver/constraints/union_test.exs | 6 ++++ test/hex_solver/constraints/version_test.exs | 4 +++ test/hex_solver/requirement_test.exs | 20 +++++++++++- test/hex_solver_test.exs | 34 +++++++++++++++++++- 15 files changed, 141 insertions(+), 5 deletions(-) diff --git a/lib/hex_solver.ex b/lib/hex_solver.ex index 98b761e..af20e33 100644 --- a/lib/hex_solver.ex +++ b/lib/hex_solver.ex @@ -24,7 +24,7 @@ defmodule HexSolver do @type result() :: %{package() => {Version.t(), repo()}} @opaque constraint() :: HexSolver.Requirement.t() - alias HexSolver.{Failure, Requirement, Solver} + alias HexSolver.{Constraint, Failure, Requirement, Solver} @doc """ Runs the version solver. @@ -83,4 +83,14 @@ defmodule HexSolver do def parse_constraint!(string) do Requirement.to_constraint!(string) end + + @doc """ + Serializes an internal solver constraint as an Elixir version requirement. + """ + @spec constraint_to_requirement!(constraint()) :: String.t() + def constraint_to_requirement!(constraint) do + requirement = Constraint.to_requirement(constraint) + Version.parse_requirement!(requirement) + requirement + end end diff --git a/lib/hex_solver/constraint.ex b/lib/hex_solver/constraint.ex index 6ef2264..a81714d 100644 --- a/lib/hex_solver/constraint.ex +++ b/lib/hex_solver/constraint.ex @@ -10,4 +10,5 @@ defprotocol HexSolver.Constraint do def intersect(left, right) def union(left, right) def compare(left, right) + def to_requirement(constraint) end diff --git a/lib/hex_solver/constraints/empty.ex b/lib/hex_solver/constraints/empty.ex index 107e2e3..4742837 100644 --- a/lib/hex_solver/constraints/empty.ex +++ b/lib/hex_solver/constraints/empty.ex @@ -34,6 +34,10 @@ defmodule HexSolver.Constraints.Empty do clauses: [] end + def to_requirement(%Empty{}) do + "< 0.0.0-0" + end + def to_string(%Empty{}) do "empty" end diff --git a/lib/hex_solver/constraints/impl.ex b/lib/hex_solver/constraints/impl.ex index ec91bad..8e4f092 100644 --- a/lib/hex_solver/constraints/impl.ex +++ b/lib/hex_solver/constraints/impl.ex @@ -32,6 +32,9 @@ defmodule HexSolver.Constraints.Impl do def compare(left, right), do: unquote(__CALLER__.module).compare(left, right) + + def to_requirement(constraint), + do: unquote(__CALLER__.module).to_requirement(constraint) end end end diff --git a/lib/hex_solver/constraints/range.ex b/lib/hex_solver/constraints/range.ex index 9364d03..a59d54b 100644 --- a/lib/hex_solver/constraints/range.ex +++ b/lib/hex_solver/constraints/range.ex @@ -381,6 +381,14 @@ defmodule HexSolver.Constraints.Range do def normalize(%Range{} = range), do: range def normalize(%Elixir.Version{} = version), do: version + def to_requirement(%Range{min: nil, max: nil}) do + ">= 0.0.0-0" + end + + def to_requirement(%Range{} = range) do + Range.to_string(range) + end + def to_string(%Range{min: nil, max: nil}) do "any" end diff --git a/lib/hex_solver/constraints/union.ex b/lib/hex_solver/constraints/union.ex index 69e7744..40f1356 100644 --- a/lib/hex_solver/constraints/union.ex +++ b/lib/hex_solver/constraints/union.ex @@ -152,6 +152,10 @@ defmodule HexSolver.Constraints.Union do defp maybe_to_range(%Elixir.Version{} = version), do: Version.to_range(version) defp maybe_to_range(other), do: other + def to_requirement(%Union{ranges: ranges}) do + Enum.map_join(ranges, " or ", &Constraint.to_requirement/1) + end + def to_string(%Union{ranges: ranges}) do Enum.map_join(ranges, " or ", &Kernel.to_string/1) end diff --git a/lib/hex_solver/constraints/version.ex b/lib/hex_solver/constraints/version.ex index 655a1ae..008bd31 100644 --- a/lib/hex_solver/constraints/version.ex +++ b/lib/hex_solver/constraints/version.ex @@ -99,6 +99,10 @@ defmodule HexSolver.Constraints.Version do end end + def to_requirement(%Version{} = version) do + Kernel.to_string(version) + end + def max(left, right) do case compare(left, right) do :lt -> right diff --git a/lib/hex_solver/requirement.ex b/lib/hex_solver/requirement.ex index 5fb80dc..e348700 100644 --- a/lib/hex_solver/requirement.ex +++ b/lib/hex_solver/requirement.ex @@ -1,7 +1,7 @@ defmodule HexSolver.Requirement do @moduledoc false - alias HexSolver.Constraints.{Range, Util} + alias HexSolver.Constraints.{Empty, Range, Util} alias HexSolver.Requirement.Parser @allowed_range_ops [:>, :>=, :<, :<=, :~>] @@ -43,7 +43,9 @@ defmodule HexSolver.Requirement do end defp delex([], acc) do - Util.union(acc) + acc + |> Enum.map(&normalize_constraint/1) + |> Util.union() end defp delex([op | rest], acc) when op in [:||, :or] do @@ -144,6 +146,24 @@ defmodule HexSolver.Requirement do defp to_version({major, minor, patch, pre, _build}), do: %Elixir.Version{major: major, minor: minor, patch: patch, pre: pre} + defp normalize_constraint(%Range{ + max: %Elixir.Version{major: 0, minor: 0, patch: 0, pre: [0]}, + include_max: false + }) do + %Empty{} + end + + defp normalize_constraint( + %Range{ + min: %Elixir.Version{major: 0, minor: 0, patch: 0, pre: [0]}, + include_min: true + } = range + ) do + %{range | min: nil, include_min: false} + end + + defp normalize_constraint(constraint), do: constraint + # Vendored from https://github.com/elixir-lang/elixir/blob/0ff6522/lib/elixir/lib/version.ex#L495 defmodule Parser do @moduledoc false diff --git a/test/hex_solver/constraint_test.exs b/test/hex_solver/constraint_test.exs index 102f00f..3dc3fd3 100644 --- a/test/hex_solver/constraint_test.exs +++ b/test/hex_solver/constraint_test.exs @@ -179,4 +179,13 @@ defmodule HexSolver.ConstraintTest do end end end + + property "constraint requirements round-trip" do + check all constraint <- constraint() do + requirement = Constraint.to_requirement(constraint) + + assert {:ok, _requirement} = Version.parse_requirement(requirement) + assert HexSolver.parse_constraint!(requirement) == constraint + end + end end diff --git a/test/hex_solver/constraints/empty_test.exs b/test/hex_solver/constraints/empty_test.exs index 3538d8b..43eae6f 100644 --- a/test/hex_solver/constraints/empty_test.exs +++ b/test/hex_solver/constraints/empty_test.exs @@ -17,6 +17,10 @@ defmodule HexSolver.Constraints.EmptyTest do assert Empty.to_string(%Empty{}) == "empty" end + test "to_requirement/1" do + assert Constraint.to_requirement(%Empty{}) == "< 0.0.0-0" + end + test "Kernel.inspect/1" do assert inspect(%Empty{}) == "#Empty<>" end diff --git a/test/hex_solver/constraints/range_test.exs b/test/hex_solver/constraints/range_test.exs index e1cb41e..e271351 100644 --- a/test/hex_solver/constraints/range_test.exs +++ b/test/hex_solver/constraints/range_test.exs @@ -2,6 +2,7 @@ defmodule HexSolver.Constraints.RangeTest do use HexSolver.Case, async: true use ExUnitProperties + alias HexSolver.Constraint alias HexSolver.Constraints.{Empty, Range, Union, Util, Version} describe "valid?/1" do @@ -724,6 +725,14 @@ defmodule HexSolver.Constraints.RangeTest do end end + test "to_string/1 describes any range" do + assert Range.to_string(%Range{}) == "any" + end + + test "to_requirement/1 serializes any range" do + assert Constraint.to_requirement(%Range{}) == ">= 0.0.0-0" + end + property "Kernel.inspect/1" do check all range <- range() do assert is_binary(inspect(range)) diff --git a/test/hex_solver/constraints/union_test.exs b/test/hex_solver/constraints/union_test.exs index ff6b90f..ab58dda 100644 --- a/test/hex_solver/constraints/union_test.exs +++ b/test/hex_solver/constraints/union_test.exs @@ -64,6 +64,12 @@ defmodule HexSolver.Constraints.UnionTest do end end + test "to_requirement/1" do + union = %Union{ranges: [v("1.0.0"), %HexSolver.Constraints.Range{min: v("2.0.0")}]} + + assert Constraint.to_requirement(union) == "1.0.0 or > 2.0.0" + end + property "Kernel.inspect/1" do check all union <- union() do assert is_binary(inspect(union)) diff --git a/test/hex_solver/constraints/version_test.exs b/test/hex_solver/constraints/version_test.exs index 61b7e5a..864c165 100644 --- a/test/hex_solver/constraints/version_test.exs +++ b/test/hex_solver/constraints/version_test.exs @@ -196,4 +196,8 @@ defmodule HexSolver.Constraints.VersionTest do assert Range.single_version?(Version.to_range(version)) end end + + test "to_requirement/1" do + assert Constraint.to_requirement(v("1.2.3")) == "1.2.3" + end end diff --git a/test/hex_solver/requirement_test.exs b/test/hex_solver/requirement_test.exs index 53932cd..9de8c5f 100644 --- a/test/hex_solver/requirement_test.exs +++ b/test/hex_solver/requirement_test.exs @@ -3,7 +3,7 @@ defmodule HexSolver.RequirementTest do use ExUnitProperties alias HexSolver.Requirement - alias HexSolver.Constraints.{Range, Union} + alias HexSolver.Constraints.{Empty, Range, Union} describe "to_constraint!/" do property "always converts" do @@ -38,6 +38,24 @@ defmodule HexSolver.RequirementTest do max: v("2.0.0-0"), include_min: true } + + assert_raise Version.InvalidRequirementError, fn -> + Requirement.to_constraint!("< 0.0.0-0 and >= 1.0.0") + end + end + + test "minimum version range" do + assert Requirement.to_constraint!(">= 0.0.0-0") == %Range{} + assert Requirement.to_constraint!("< 0.0.0-0") == %Empty{} + + assert Requirement.to_constraint!(">= 0.0.0-0 and < 1.0.0") == %Range{ + max: v("1.0.0") + } + + assert Requirement.to_constraint!("< 0.0.0-0 or >= 1.0.0") == %Range{ + min: v("1.0.0"), + include_min: true + } end end end diff --git a/test/hex_solver_test.exs b/test/hex_solver_test.exs index 2cf58cb..3c6bb6d 100644 --- a/test/hex_solver_test.exs +++ b/test/hex_solver_test.exs @@ -2,7 +2,7 @@ defmodule HexSolverTest do use HexSolver.Case, async: true alias HexSolver.Registry.Process, as: Registry - alias HexSolver.Constraints.Range + alias HexSolver.Constraints.{Empty, Range} defp run(dependencies) do HexSolver.run(Registry, to_dependencies(dependencies), [], []) @@ -63,4 +63,36 @@ defmodule HexSolverTest do HexSolver.parse_constraint!("1.2.3.4") end end + + describe "constraint_to_requirement!/1" do + test "serializes any constraint as a valid requirement" do + requirement = HexSolver.constraint_to_requirement!(%Range{}) + + assert requirement == ">= 0.0.0-0" + assert {:ok, _requirement} = Version.parse_requirement(requirement) + assert HexSolver.parse_constraint!(requirement) == %Range{} + end + + test "serializes version constraints" do + assert HexSolver.constraint_to_requirement!(Version.parse!("1.2.3")) == "1.2.3" + end + + test "serializes bounded range constraints" do + constraint = HexSolver.parse_constraint!("~> 1.2") + assert HexSolver.constraint_to_requirement!(constraint) == "~> 1.2" + end + + test "serializes union constraints" do + constraint = HexSolver.parse_constraint!("~> 1.0 or ~> 2.0") + assert HexSolver.constraint_to_requirement!(constraint) == "~> 1.0 or ~> 2.0" + end + + test "serializes empty constraint as a valid requirement" do + requirement = HexSolver.constraint_to_requirement!(%Empty{}) + + assert requirement == "< 0.0.0-0" + assert {:ok, _requirement} = Version.parse_requirement(requirement) + assert HexSolver.parse_constraint!(requirement) == %Empty{} + end + end end