From b81fa6a654543bad79f90471dde667d6d707665c Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 13 Sep 2026 14:25:56 +0200 Subject: [PATCH 1/3] Improve stacktrace :file info type Follow up to #15798 --- lib/elixir/lib/module/types/apply.ex | 9 ++++++++- lib/elixir/lib/module/types/expr.ex | 9 ++++++++- lib/elixir/test/elixir/module/types/expr_test.exs | 5 ++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 2e75955f2b..645af798b8 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -120,9 +120,16 @@ defmodule Module.Types.Apply do args_or_none = opt_union(list(term()), atom([:none])) custom_info_key = opt_difference(atom(), atom([:file, :line, :error_info])) + # OTP declares the file entry in stacktrace_extrainfo() as unicode:chardata(), + # which also admits nested and improper (binary tailed) lists + chardata = + binary() + |> opt_union(empty_list()) + |> opt_union(non_empty_list(term(), opt_union(binary(), empty_list()))) + extra_info = list( - tuple([atom([:file]), opt_union(list(integer()), binary())]) + tuple([atom([:file]), chardata]) |> opt_union(tuple([atom([:line]), integer()])) |> opt_union(tuple([atom([:error_info]), open_map()])) |> opt_union(tuple([custom_info_key, term()])) diff --git a/lib/elixir/lib/module/types/expr.ex b/lib/elixir/lib/module/types/expr.ex index c1671dcae6..14fce7d1ba 100644 --- a/lib/elixir/lib/module/types/expr.ex +++ b/lib/elixir/lib/module/types/expr.ex @@ -43,9 +43,16 @@ defmodule Module.Types.Expr do custom_info_key = opt_difference(atom(), atom([:file, :line, :error_info])) + # OTP declares the file entry in stacktrace_extrainfo() as unicode:chardata(), + # which also admits nested and improper (binary tailed) lists + chardata = + binary() + |> opt_union(empty_list()) + |> opt_union(non_empty_list(term(), opt_union(binary(), empty_list()))) + extra_info = list( - tuple([atom([:file]), opt_union(list(integer()), binary())]) + tuple([atom([:file]), chardata]) |> opt_union(tuple([atom([:line]), integer()])) |> opt_union(tuple([atom([:error_info]), open_map()])) |> opt_union(tuple([custom_info_key, term()])) diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 764aa27e89..7b66cc04c8 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -3470,7 +3470,10 @@ defmodule Module.Types.ExprTest do _ -> "unknown" end end - ) == opt_union(list(integer()), binary()) + ) == + binary() + |> opt_union(empty_list()) + |> opt_union(non_empty_list(term(), opt_union(binary(), empty_list()))) assert typecheck!( try do From 6d47e11537670f395a5af0a66f29c95eb938ed13 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 13 Sep 2026 22:02:55 +0200 Subject: [PATCH 2/3] Define chardata as recursive type Introduce scoped recursive nodes that can be embedded in compiled signatures. Use the recursive pretty printer and recognize scoped node IDs when selecting it. Assisted-by: Codex:GPT-6 --- lib/elixir/lib/module/types/apply.ex | 8 +--- lib/elixir/lib/module/types/descr.ex | 47 +++++++++++++++++-- lib/elixir/lib/module/types/expr.ex | 8 +--- .../test/elixir/module/types/expr_test.exs | 18 +++++-- .../elixir/module/types/recursive_test.exs | 43 +++++++++++++++++ 5 files changed, 105 insertions(+), 19 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 645af798b8..8efe6f0674 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -120,12 +120,8 @@ defmodule Module.Types.Apply do args_or_none = opt_union(list(term()), atom([:none])) custom_info_key = opt_difference(atom(), atom([:file, :line, :error_info])) - # OTP declares the file entry in stacktrace_extrainfo() as unicode:chardata(), - # which also admits nested and improper (binary tailed) lists - chardata = - binary() - |> opt_union(empty_list()) - |> opt_union(non_empty_list(term(), opt_union(binary(), empty_list()))) + # OTP declares the file entry in stacktrace_extrainfo() as unicode:chardata() + chardata = Module.Types.Descr.chardata() extra_info = list( diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 393f887c65..3435521eb5 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -119,6 +119,39 @@ defmodule Module.Types.Descr do @boolset :sets.from_list([true, false], version: 2) def boolean(), do: %{atom: {:union, @boolset}} + @doc """ + The recursive `unicode:chardata()` type, as declared by Erlang/OTP: + + chardata() = charlist() | unicode_binary() + charlist() = maybe_improper_list(char() | unicode_binary() | charlist(), + unicode_binary() | []) + + """ + def chardata() do + %{chardata: node} = + recursive( + %{ + chardata: &__MODULE__.chardata_equation/1, + chardata_head: &__MODULE__.chardata_head_equation/1 + }, + __MODULE__ + ) + + node + end + + @doc false + def chardata_equation(recur) do + binary() + |> bare_union(empty_list()) + |> bare_union(non_empty_list(recur.(:chardata_head), bare_union(binary(), empty_list()))) + end + + # The head has its own equation because a node cannot be unfolded from + # within its own generator, and unions unfold their operands. + @doc false + def chardata_head_equation(recur), do: bare_union(integer(), recur.(:chardata)) + ## Nodes defp make_node(id, state, generator), do: {id, state, generator} @@ -138,11 +171,19 @@ defmodule Module.Types.Descr do Builds recursive type nodes from mutually recursive equations. Generators receive `recur`, which returns the node for a named equation. + + Each equation is identified by a unique reference, which means the + resulting nodes cannot be escaped into compiled code. Give a `scope` + to identify the equations by `{scope, name}` instead. In such cases, + the scope must be unique across all recursive types it may meet and + the generators must be remote captures, so the nodes are made of + literals that `Macro.escape/1` supports. """ - def recursive(equations) when is_map(equations) do + def recursive(equations, scope \\ nil) when is_map(equations) do state = Map.new(equations, fn {name, generator} -> - {name, {make_ref(), generator}} + id = if scope == nil, do: make_ref(), else: {scope, name} + {name, {id, generator}} end) Map.new(state, fn {name, {id, generator}} -> @@ -729,7 +770,7 @@ defmodule Module.Types.Descr do # Find recursive nodes without running their generators. defp contains_recur?({id, state, generator}) - when is_reference(id) and is_map(state) and is_function(generator, 1), + when (is_reference(id) or is_tuple(id)) and is_map(state) and is_function(generator, 1), do: true defp contains_recur?(term) when is_map(term), diff --git a/lib/elixir/lib/module/types/expr.ex b/lib/elixir/lib/module/types/expr.ex index 14fce7d1ba..ac2eee1965 100644 --- a/lib/elixir/lib/module/types/expr.ex +++ b/lib/elixir/lib/module/types/expr.ex @@ -43,12 +43,8 @@ defmodule Module.Types.Expr do custom_info_key = opt_difference(atom(), atom([:file, :line, :error_info])) - # OTP declares the file entry in stacktrace_extrainfo() as unicode:chardata(), - # which also admits nested and improper (binary tailed) lists - chardata = - binary() - |> opt_union(empty_list()) - |> opt_union(non_empty_list(term(), opt_union(binary(), empty_list()))) + # OTP declares the file entry in stacktrace_extrainfo() as unicode:chardata() + chardata = Module.Types.Descr.chardata() extra_info = list( diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 7b66cc04c8..1735da9102 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -468,6 +468,18 @@ defmodule Module.Types.ExprTest do ]) ) == none() + assert typecheck!( + :erlang.raise(:error, :oops, [ + {__MODULE__, :example, 1, [file: [~c"src/", ["example" | ".ex"]]]} + ]) + ) == none() + + assert typeerror!( + :erlang.raise(:error, :oops, [ + {__MODULE__, :example, 1, [file: [~c"src/", [:invalid]]]} + ]) + ) =~ "incompatible types given to :erlang.raise/3" + assert typeerror!( :erlang.raise(:error, :oops, [ {__MODULE__, :example, 1, [line: :unknown]} @@ -3470,10 +3482,8 @@ defmodule Module.Types.ExprTest do _ -> "unknown" end end - ) == - binary() - |> opt_union(empty_list()) - |> opt_union(non_empty_list(term(), opt_union(binary(), empty_list()))) + ) + |> equal?(chardata()) assert typecheck!( try do diff --git a/lib/elixir/test/elixir/module/types/recursive_test.exs b/lib/elixir/test/elixir/module/types/recursive_test.exs index 87ded5dadc..39be66bdaa 100644 --- a/lib/elixir/test/elixir/module/types/recursive_test.exs +++ b/lib/elixir/test/elixir/module/types/recursive_test.exs @@ -43,6 +43,34 @@ defmodule Module.Types.RecursiveTest do |> unfold() end + test "scoped nodes can be escaped and pretty printed" do + assert {{Module.Types.Descr, :chardata}, _state, _generator} = chardata() + + # Scoped nodes are made of literals, so they can be embedded in compiled code + assert Macro.escape(chardata()) |> Code.eval_quoted() |> elem(0) |> equal?(chardata()) + + expected = + """ + recur( + t1, + binary() or empty_list() or + non_empty_list( + recur( + t2, + binary() or empty_list() or integer() or non_empty_list(t2, binary() or empty_list()) + ), + binary() or empty_list() + ) + ) + """ + |> String.trim() + + assert to_quoted_string(chardata()) == expected + + assert to_quoted(tuple([atom([:file]), chardata()])) == + {:{}, [], [to_quoted(atom([:file])), to_quoted(chardata())]} + end + test "node infrastructure" do descr = integer() @@ -166,6 +194,21 @@ defmodule Module.Types.RecursiveTest do assert subtype?(non_empty_list(binary(), binary()), chardata) refute subtype?(pid(), chardata) + ## The chardata/0 constructor is the real unicode:chardata() + assert subtype?(binary(), chardata()) + assert subtype?(empty_list(), chardata()) + assert subtype?(list(integer()), chardata()) + assert subtype?(list(binary()), chardata()) + assert subtype?(non_empty_list(integer(), binary()), chardata()) + # nested chardata, which the equation above does not admit + assert subtype?(list(list(integer())), chardata()) + assert subtype?(list(list(list(binary()))), chardata()) + assert subtype?(list(non_empty_list(integer(), binary())), chardata()) + refute subtype?(list(atom()), chardata()) + refute subtype?(list(float()), chardata()) + refute subtype?(non_empty_list(integer(), atom()), chardata()) + refute subtype?(atom(), chardata()) + ## expression trees # Expr = integer() | {atom, Expr, Expr}, Binop = {atom, Expr, Expr} %{Expr: nexpr_node, Binop: nbinop_node} = From 393b7eab91213e2c48d65aec8251b9c1187db1da Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 8 Nov 2026 18:25:15 +0100 Subject: [PATCH 3/3] Move chardata type construction to Module.Types.Of Assisted-by: Codex:GPT-6 --- lib/elixir/lib/module/types/apply.ex | 2 +- lib/elixir/lib/module/types/descr.ex | 33 ------------------- lib/elixir/lib/module/types/expr.ex | 2 +- lib/elixir/lib/module/types/of.ex | 33 +++++++++++++++++++ .../test/elixir/module/types/expr_test.exs | 1 + .../elixir/module/types/recursive_test.exs | 3 +- 6 files changed, 38 insertions(+), 36 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 8efe6f0674..a7d6dde647 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -121,7 +121,7 @@ defmodule Module.Types.Apply do custom_info_key = opt_difference(atom(), atom([:file, :line, :error_info])) # OTP declares the file entry in stacktrace_extrainfo() as unicode:chardata() - chardata = Module.Types.Descr.chardata() + chardata = Module.Types.Of.chardata() extra_info = list( diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 3435521eb5..b6e9722969 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -119,39 +119,6 @@ defmodule Module.Types.Descr do @boolset :sets.from_list([true, false], version: 2) def boolean(), do: %{atom: {:union, @boolset}} - @doc """ - The recursive `unicode:chardata()` type, as declared by Erlang/OTP: - - chardata() = charlist() | unicode_binary() - charlist() = maybe_improper_list(char() | unicode_binary() | charlist(), - unicode_binary() | []) - - """ - def chardata() do - %{chardata: node} = - recursive( - %{ - chardata: &__MODULE__.chardata_equation/1, - chardata_head: &__MODULE__.chardata_head_equation/1 - }, - __MODULE__ - ) - - node - end - - @doc false - def chardata_equation(recur) do - binary() - |> bare_union(empty_list()) - |> bare_union(non_empty_list(recur.(:chardata_head), bare_union(binary(), empty_list()))) - end - - # The head has its own equation because a node cannot be unfolded from - # within its own generator, and unions unfold their operands. - @doc false - def chardata_head_equation(recur), do: bare_union(integer(), recur.(:chardata)) - ## Nodes defp make_node(id, state, generator), do: {id, state, generator} diff --git a/lib/elixir/lib/module/types/expr.ex b/lib/elixir/lib/module/types/expr.ex index ac2eee1965..b87adc14c2 100644 --- a/lib/elixir/lib/module/types/expr.ex +++ b/lib/elixir/lib/module/types/expr.ex @@ -44,7 +44,7 @@ defmodule Module.Types.Expr do custom_info_key = opt_difference(atom(), atom([:file, :line, :error_info])) # OTP declares the file entry in stacktrace_extrainfo() as unicode:chardata() - chardata = Module.Types.Descr.chardata() + chardata = Module.Types.Of.chardata() extra_info = list( diff --git a/lib/elixir/lib/module/types/of.ex b/lib/elixir/lib/module/types/of.ex index 0ace2d16ed..ab20290a52 100644 --- a/lib/elixir/lib/module/types/of.ex +++ b/lib/elixir/lib/module/types/of.ex @@ -17,6 +17,39 @@ defmodule Module.Types.Of do @binary binary() @bitstring bitstring() + @doc """ + The recursive `unicode:chardata()` type, as declared by Erlang/OTP: + + chardata() = charlist() | unicode_binary() + charlist() = maybe_improper_list(char() | unicode_binary() | charlist(), + unicode_binary() | []) + + """ + def chardata() do + %{chardata: node} = + recursive( + %{ + chardata: &__MODULE__.chardata_equation/1, + chardata_head: &__MODULE__.chardata_head_equation/1 + }, + __MODULE__ + ) + + node + end + + @doc false + def chardata_equation(recur) do + binary() + |> bare_union(empty_list()) + |> bare_union(non_empty_list(recur.(:chardata_head), bare_union(binary(), empty_list()))) + end + + # The head has its own equation because a node cannot be unfolded from + # within its own generator, and unions unfold their operands. + @doc false + def chardata_head_equation(recur), do: bare_union(integer(), recur.(:chardata)) + ## Variables @doc """ diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 1735da9102..d6ca25e57f 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -8,6 +8,7 @@ defmodule Module.Types.ExprTest do import TypeHelper import Module.Types.Descr + import Module.Types.Of, only: [chardata: 0] defmacro domain_key(arg) when is_atom(arg), do: [arg] defmacro generated_foo_call(x) do diff --git a/lib/elixir/test/elixir/module/types/recursive_test.exs b/lib/elixir/test/elixir/module/types/recursive_test.exs index 39be66bdaa..c846a111b8 100644 --- a/lib/elixir/test/elixir/module/types/recursive_test.exs +++ b/lib/elixir/test/elixir/module/types/recursive_test.exs @@ -7,6 +7,7 @@ defmodule Module.Types.RecursiveTest do use ExUnit.Case, async: true import Module.Types.Descr + import Module.Types.Of, only: [chardata: 0] setup do Process.flag(:max_heap_size, %{size: 25_000_000, kill: true, error_logger: false}) @@ -44,7 +45,7 @@ defmodule Module.Types.RecursiveTest do end test "scoped nodes can be escaped and pretty printed" do - assert {{Module.Types.Descr, :chardata}, _state, _generator} = chardata() + assert {{Module.Types.Of, :chardata}, _state, _generator} = chardata() # Scoped nodes are made of literals, so they can be embedded in compiled code assert Macro.escape(chardata()) |> Code.eval_quoted() |> elem(0) |> equal?(chardata())