From b9d73b9cff36a5c9db081cfbcc7082418f63c98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 12 Oct 2026 22:55:26 +0200 Subject: [PATCH] Format solver structs without String.Chars dispatch Hex vendors the solver into its archive. Mix consolidates the project's protocols during compile with code paths that exclude archives, so the consolidated String.Chars has no impls for the solver structs. Formatting a failed resolution in the same VM after compile interpolated terms, package ranges and constraints and raised Protocol.UndefinedError instead of explaining the failure. Call each struct's to_string/1 directly, and add to_string/1 to the Constraint protocol for constraints, which can be any of four structs. The String.Chars impls stay for library users. --- lib/hex_solver/assignment.ex | 8 ++-- lib/hex_solver/constraint.ex | 1 + lib/hex_solver/constraints/impl.ex | 3 ++ lib/hex_solver/constraints/range.ex | 2 +- lib/hex_solver/constraints/union.ex | 4 +- lib/hex_solver/constraints/version.ex | 4 ++ lib/hex_solver/incompatibility.ex | 7 ++-- lib/hex_solver/package_range.ex | 8 ++-- lib/hex_solver/solver.ex | 15 +++---- lib/hex_solver/term.ex | 4 +- test/hex_solver/failure_test.exs | 59 +++++++++++++++++++++++++++ 11 files changed, 92 insertions(+), 23 deletions(-) diff --git a/lib/hex_solver/assignment.ex b/lib/hex_solver/assignment.ex index 58271b5..74ba494 100644 --- a/lib/hex_solver/assignment.ex +++ b/lib/hex_solver/assignment.ex @@ -1,7 +1,7 @@ defmodule HexSolver.Assignment do @moduledoc false - alias HexSolver.{Assignment, Term} + alias HexSolver.{Assignment, Incompatibility, Term} defstruct term: nil, decision_level: nil, @@ -17,7 +17,7 @@ defmodule HexSolver.Assignment do end def to_string(%Assignment{term: term}) do - Kernel.to_string(term) + Term.to_string(term) end defimpl String.Chars do @@ -34,10 +34,10 @@ defmodule HexSolver.Assignment do }, _opts ) do - "#Assignment" + "#Assignment" end defp maybe(_prefix, nil), do: "" - defp maybe(prefix, value), do: "#{prefix}#{value}" + defp maybe(prefix, value), do: "#{prefix}#{Incompatibility.to_string(value)}" end end diff --git a/lib/hex_solver/constraint.ex b/lib/hex_solver/constraint.ex index a81714d..0071329 100644 --- a/lib/hex_solver/constraint.ex +++ b/lib/hex_solver/constraint.ex @@ -11,4 +11,5 @@ defprotocol HexSolver.Constraint do def union(left, right) def compare(left, right) def to_requirement(constraint) + def to_string(constraint) end diff --git a/lib/hex_solver/constraints/impl.ex b/lib/hex_solver/constraints/impl.ex index 8e4f092..6597b2e 100644 --- a/lib/hex_solver/constraints/impl.ex +++ b/lib/hex_solver/constraints/impl.ex @@ -35,6 +35,9 @@ defmodule HexSolver.Constraints.Impl do def to_requirement(constraint), do: unquote(__CALLER__.module).to_requirement(constraint) + + def to_string(constraint), + do: unquote(__CALLER__.module).to_string(constraint) end end end diff --git a/lib/hex_solver/constraints/range.ex b/lib/hex_solver/constraints/range.ex index 76e77d1..86f72d2 100644 --- a/lib/hex_solver/constraints/range.ex +++ b/lib/hex_solver/constraints/range.ex @@ -440,7 +440,7 @@ defmodule HexSolver.Constraints.Range do defimpl Inspect do def inspect(range, _opts) do - "#Range<#{range}>" + "#Range<#{HexSolver.Constraints.Range.to_string(range)}>" end end end diff --git a/lib/hex_solver/constraints/union.ex b/lib/hex_solver/constraints/union.ex index c678da9..0db6356 100644 --- a/lib/hex_solver/constraints/union.ex +++ b/lib/hex_solver/constraints/union.ex @@ -157,7 +157,7 @@ defmodule HexSolver.Constraints.Union do end def to_string(%Union{ranges: ranges}) do - Enum.map_join(ranges, " or ", &Kernel.to_string/1) + Enum.map_join(ranges, " or ", &Constraint.to_string/1) end defimpl String.Chars do @@ -166,7 +166,7 @@ defmodule HexSolver.Constraints.Union do defimpl Inspect do def inspect(union, _opts) do - "#Union<#{union}>" + "#Union<#{HexSolver.Constraints.Union.to_string(union)}>" end end end diff --git a/lib/hex_solver/constraints/version.ex b/lib/hex_solver/constraints/version.ex index e42f499..b8db504 100644 --- a/lib/hex_solver/constraints/version.ex +++ b/lib/hex_solver/constraints/version.ex @@ -103,6 +103,10 @@ defmodule HexSolver.Constraints.Version do Kernel.to_string(version) end + def to_string(%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/incompatibility.ex b/lib/hex_solver/incompatibility.ex index 8783d15..39b22c5 100644 --- a/lib/hex_solver/incompatibility.ex +++ b/lib/hex_solver/incompatibility.ex @@ -73,7 +73,7 @@ defmodule HexSolver.Incompatibility do 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)}" + "no versions of #{package_name(term, opts)} match #{bright(Constraint.to_string(term.package_range.constraint), opts)}" end def to_string(%Incompatibility{cause: :package_not_found, terms: terms}, opts) do @@ -83,7 +83,8 @@ defmodule HexSolver.Incompatibility do 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)}" + + "#{package_name(term, opts)} is #{bright(Constraint.to_string(term.package_range.constraint), opts)}" end def to_string(%Incompatibility{terms: []}, _opts) do @@ -409,7 +410,7 @@ defmodule HexSolver.Incompatibility do defp term_abs(%Term{} = term), do: %Term{term | positive: true} - defp bright_term_abs(term, opts), do: bright(term_abs(term), opts) + defp bright_term_abs(term, opts), do: bright(Term.to_string(term_abs(term)), opts) defimpl String.Chars do defdelegate to_string(incompatibility), to: HexSolver.Incompatibility diff --git a/lib/hex_solver/package_range.ex b/lib/hex_solver/package_range.ex index 0697d84..8e0a867 100644 --- a/lib/hex_solver/package_range.ex +++ b/lib/hex_solver/package_range.ex @@ -1,7 +1,7 @@ defmodule HexSolver.PackageRange do @moduledoc false - alias HexSolver.PackageRange + alias HexSolver.{Constraint, PackageRange} alias HexSolver.Constraints.Range defstruct repo: nil, @@ -18,7 +18,7 @@ defmodule HexSolver.PackageRange do do: "#{repo}/#{name}#{constraint(constraint)}" defp constraint(%Range{min: nil, max: nil}), do: "" - defp constraint(constraint), do: " #{constraint}" + defp constraint(constraint), do: " " <> Constraint.to_string(constraint) defimpl String.Chars do defdelegate to_string(package_range), to: HexSolver.PackageRange @@ -26,9 +26,9 @@ defmodule HexSolver.PackageRange do defimpl Inspect do def inspect(%{repo: nil, name: name, constraint: constraint}, _opts), - do: "#PackageRange<#{name} #{constraint}>" + do: "#PackageRange<#{name} #{Constraint.to_string(constraint)}>" def inspect(%{repo: repo, name: name, constraint: constraint}, _opts), - do: "#PackageRange<#{repo}/#{name} #{constraint}>" + do: "#PackageRange<#{repo}/#{name} #{Constraint.to_string(constraint)}>" end end diff --git a/lib/hex_solver/solver.ex b/lib/hex_solver/solver.ex index 43bc97f..0845cce 100644 --- a/lib/hex_solver/solver.ex +++ b/lib/hex_solver/solver.ex @@ -2,6 +2,7 @@ defmodule HexSolver.Solver do @moduledoc false alias HexSolver.{ + Assignment, Incompatibility, PackageLister, PackageRange, @@ -96,7 +97,7 @@ defmodule HexSolver.Solver do defp propagate_incompatibility([], unsatisfied, incompatibility, state, _opts) do # Only one term in the incompatibility was unsatisfied unsatisfied = %{unsatisfied | positive: not unsatisfied.positive} - Logger.debug("RESOLVER: derived #{unsatisfied}") + Logger.debug("RESOLVER: derived #{Term.to_string(unsatisfied)}") solution = PartialSolution.derive(state.solution, unsatisfied, incompatibility) {:ok, unsatisfied.package_range.name, %{state | solution: solution}} @@ -146,7 +147,7 @@ defmodule HexSolver.Solver do state.solution else package_range = %PackageRange{package_range | constraint: version} - Logger.debug("RESOLVER: selecting #{package_range}") + Logger.debug("RESOLVER: selecting #{PackageRange.to_string(package_range)}") PartialSolution.decide(state.solution, package_range) end @@ -164,7 +165,7 @@ defmodule HexSolver.Solver do end defp add_incompatibility(state, incompatibility) do - Logger.debug("RESOLVER: fact #{incompatibility}") + Logger.debug("RESOLVER: fact #{Incompatibility.to_string(incompatibility)}") incompatibilities = Enum.reduce(incompatibility.terms, state.incompatibilities, fn term, incompatibilities -> @@ -189,7 +190,7 @@ defmodule HexSolver.Solver do # incompatibility that encapsulates the root cause of the conflict and backtracks # until the new incompatibility will allow propagation to deduce new assignments. defp conflict_resolution(state, incompatibility) do - Logger.debug("RESOLVER: conflict #{incompatibility}") + Logger.debug("RESOLVER: conflict #{Incompatibility.to_string(incompatibility)}") do_conflict_resolution(state, incompatibility, false) catch :throw, {__MODULE__, :conflict_resolution, incompatibility, state} -> @@ -319,9 +320,9 @@ defmodule HexSolver.Solver do Logger.debug(""" RESOLVER: conflict resolution - #{resolution.most_recent_term} is#{partially} satisfied by #{resolution.most_recent_satisfier} - which is caused by #{resolution.most_recent_satisfier.cause} - thus #{incompatibility}\ + #{Term.to_string(resolution.most_recent_term)} is#{partially} satisfied by #{Assignment.to_string(resolution.most_recent_satisfier)} + which is caused by #{Incompatibility.to_string(resolution.most_recent_satisfier.cause)} + thus #{Incompatibility.to_string(incompatibility)}\ """) do_conflict_resolution(state, incompatibility, true) diff --git a/lib/hex_solver/term.ex b/lib/hex_solver/term.ex index 9620fc5..ea5e533 100644 --- a/lib/hex_solver/term.ex +++ b/lib/hex_solver/term.ex @@ -122,7 +122,7 @@ defmodule HexSolver.Term do end def to_string(%Term{package_range: package_range, positive: positive}) do - "#{positive(positive)}#{package_range}" + "#{positive(positive)}#{PackageRange.to_string(package_range)}" end defp positive(true), do: "" @@ -137,7 +137,7 @@ defmodule HexSolver.Term do %Term{package_range: package_range, positive: positive, optional: optional}, _opts ) do - "#Term<#{positive(positive)}#{package_range}#{optional(optional)}>" + "#Term<#{positive(positive)}#{PackageRange.to_string(package_range)}#{optional(optional)}>" end defp positive(true), do: "" diff --git a/test/hex_solver/failure_test.exs b/test/hex_solver/failure_test.exs index ba0c875..81730d6 100644 --- a/test/hex_solver/failure_test.exs +++ b/test/hex_solver/failure_test.exs @@ -118,4 +118,63 @@ defmodule HexSolver.FailureTest do So, because "your app" depends on "top2", version solving failed.\ """ end + + # Projects consolidate String.Chars without the impls from archives, such as + # Hex which vendors the solver, so formatting can't dispatch on solver structs + test "formats failures without String.Chars dispatch on solver structs" do + Registry.put("foo", "1.0.0", [{"bar", "~> 2.0 or ~> 3.0"}]) + Registry.put("bar", "1.0.0", []) + Registry.put("bar", "2.0.0", [{"baz", "~> 2.0"}]) + Registry.put("baz", "1.0.0", []) + + dispatched = + trace_string_chars(fn -> + assert {:error, incompatibility} = + HexSolver.Solver.run( + Registry, + to_dependencies([{"foo", "~> 1.0"}, {"baz", "~> 1.0"}]), + [], + [] + ) + + Failure.write(incompatibility) + Failure.write(incompatibility, ansi: true) + inspect(incompatibility) + end) + + assert dispatched != [] + refute Enum.any?(dispatched, &solver_struct?/1) + end + + defp trace_string_chars(fun) do + tracer = spawn_link(fn -> collect_string_chars([]) end) + :erlang.trace_pattern({String.Chars, :to_string, 1}, true, []) + :erlang.trace(self(), true, [:call, {:tracer, tracer}]) + + try do + fun.() + after + :erlang.trace(self(), false, [:call]) + :erlang.trace_pattern({String.Chars, :to_string, 1}, false, []) + end + + ref = :erlang.trace_delivered(self()) + assert_receive {:trace_delivered, _pid, ^ref} + send(tracer, {:values, self()}) + assert_receive {:values, values} + values + end + + defp collect_string_chars(values) do + receive do + {:trace, _pid, :call, {String.Chars, :to_string, [value]}} -> + collect_string_chars([value | values]) + + {:values, pid} -> + send(pid, {:values, values}) + end + end + + defp solver_struct?(%module{}), do: String.starts_with?(inspect(module), "HexSolver.") + defp solver_struct?(_value), do: false end