Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 86 additions & 2 deletions lib/elixir/lib/kernel/typespec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ defmodule Kernel.Typespec do
## Translation from Elixir AST to typespec AST

@doc false
def translate_typespecs_for_module(_set, bag) do
def translate_typespecs_for_module(set, bag) do
type_typespecs = take_typespecs(bag, [:type, :opaque, :typep])
defined_type_pairs = collect_defined_type_pairs(type_typespecs)

Expand All @@ -246,9 +246,67 @@ defmodule Kernel.Typespec do
optional_callbacks = :lists.flatten(get_typespecs(bag, :optional_callbacks))
used_types = filter_used_types(types, state)

store_types_descr(set, type_typespecs)

{used_types, specs, callbacks, macrocallbacks, optional_callbacks}
end

# Convert `@type`/`@opaque`/`@typep` ASTs into `Module.Types.Descr`
# values and store them in the module's data table under
# `{:elixir, :types_descr}`. The checker reads this table to
# resolve struct field types.
#
# Parametric types (arity > 0) are skipped — they are not yet
# representable in `Descr`. Cycles raise a compile error.
defp store_types_descr(_set, []), do: :ok

defp store_types_descr(set, type_typespecs) do
# Skip if the converter isn't available yet (early bootstrap
# compilation order). The conversion is best-effort metadata for
# the checker; missing it just means struct field types fall back
# to `dynamic()` for those modules.
if :code.ensure_loaded(Module.Types.Typespec) == {:module, Module.Types.Typespec} do
{descr_map, _} =
:lists.foldl(&convert_type_to_descr/2, {%{}, %{}}, type_typespecs)

:ets.insert(set, {{:elixir, :types_descr}, descr_map})
end

:ok
end

# Convert one type's AST into a `Descr` and accumulate into `defined`.
# The type currently being converted is marked `:pending` so a
# self-reference inside its own body can be detected. Both recursive
# and parametric references degrade the whole type to `dynamic()` —
# those features will be lit up by later PRs; for now the alias is
# stored but treated as opaque-from-the-checker's-view.
defp convert_type_to_descr({kind, expr, pos}, {acc, defined}) do
with {:"::", _, [{name, _meta, args}, definition]} <- expr,
true <- arg_count(args) == 0 do
env = :elixir_module.get_cached_env(pos)
state = %{module: env.module, defined: Map.put(defined, {name, 0}, :pending)}

descr =
case Module.Types.Typespec.to_descr(definition, state) do
{:ok, descr} -> descr
{:error, _reason} -> Module.Types.Descr.dynamic()
end

entry = {descr_kind_for(kind), descr}
{Map.put(acc, {name, 0}, entry), Map.put(defined, {name, 0}, entry)}
else
_ -> {acc, defined}
end
end

defp arg_count(args) when is_atom(args), do: 0
defp arg_count(args) when is_list(args), do: length(args)

defp descr_kind_for(:type), do: :type
defp descr_kind_for(:typep), do: :typep
defp descr_kind_for(:opaque), do: :opaque

defp collect_defined_type_pairs(type_typespecs) do
fun = fn {_kind, expr, pos}, type_pairs ->
%{file: file, line: line} = env = :elixir_module.get_cached_env(pos)
Expand Down Expand Up @@ -616,7 +674,7 @@ defmodule Kernel.Typespec do
)

fun = fn {field, _} ->
if not Enum.any?(struct_info, &(&1.field == field)) do
if not :lists.any(fn info -> info.field == field end, struct_info) do
compile_error(
caller,
"undefined field #{inspect(field)} on struct #{inspect(module)}"
Expand Down Expand Up @@ -801,6 +859,32 @@ defmodule Kernel.Typespec do
typespec({name, meta, args}, vars, caller, state)

true ->
:elixir_env.trace({:type_reference, meta, remote, {name, length(args)}}, caller)

# Ensure the referenced module is compiled before we proceed, so that
# store_types_descr / fetch_remote_types can read the ExCk chunk from
# the in-memory binary. This mirrors how struct expansion waits for its
# module via Kernel.ErrorHandler.ensure_compiled.
#
# Deduplicate per-process: within one module compile, many @type
# declarations can reference the same remote (e.g. Calendar.year(),
# Calendar.month(), ...). After the first call the parallel compiler has
# already resolved that module, so subsequent calls are no-ops but still
# cross into the error handler. We cache resolved remotes in the process
# dictionary under a system-reserved key to avoid redundant calls.
if :erlang.get(:elixir_compiler_info) != :undefined do
ensured =
case :erlang.get(:"$elixir_typespec_ensured") do
:undefined -> %{}
map -> map
end

unless is_map_key(ensured, remote) do
Kernel.ErrorHandler.ensure_compiled(remote, :module, :soft, caller.line)
:erlang.put(:"$elixir_typespec_ensured", Map.put(ensured, remote, true))
end
end

{remote_spec, state} = typespec(remote, vars, caller, state)
{name_spec, state} = typespec(name, vars, caller, state)
type = {remote_spec, meta, name_spec, args}
Expand Down
44 changes: 44 additions & 0 deletions lib/elixir/lib/module/parallel_checker.ex
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ defmodule Module.ParallelChecker do
for({function, :def, _meta, _clauses} <- map.definitions, do: function)

cache_info(table, map.module, exports, Map.new(map.deprecated), signatures)
cache_types_descr_from_data_tables(table, map.module)
{elixir_mode(map.attributes), module_map_to_module_tuple(map)}
end

Expand All @@ -492,6 +493,45 @@ defmodule Module.ParallelChecker do
end)
end

# Snapshot the module's `{:elixir, :types_descr}` ETS entry into the
# long-lived checker table so the checker can resolve type aliases
# after the module's own data tables have been torn down.
defp cache_types_descr_from_data_tables(table, module) do
try do
{set, _bag} = :elixir_module.data_tables(module)

case :ets.lookup(set, {:elixir, :types_descr}) do
[{_, descr_map}] when is_map(descr_map) ->
Enum.each(descr_map, fn {{name, arity}, entry} ->
:ets.insert(table, {{module, :type, name, arity}, entry})
end)

_ ->
:ok
end
catch
_, _ -> :ok
end
end

@doc """
Returns the `Descr` for a user-declared `@type`/`@opaque` if cached.

Returns `nil` when the alias is unknown, parametric (arity > 0), or
the cache is unavailable. Returns the `:opaque`/`:type` entry tuple
so callers can implement opacity rules.
"""
def fetch_type(nil, _module, _name, _arity), do: nil
def fetch_type(:none, _module, _name, _arity), do: nil

def fetch_type({_checker, table}, module, name, arity)
when is_atom(module) and is_atom(name) and is_integer(arity) do
case :ets.lookup(table, {module, :type, name, arity}) do
[{_, entry}] -> entry
_ -> nil
end
end

defp cache_chunk(table, module, contents) do
Enum.each(contents.exports, fn {{fun, arity}, info} ->
sig =
Expand All @@ -506,6 +546,10 @@ defmodule Module.ParallelChecker do
)
end)

for {{name, arity}, entry} <- Map.get(contents, :types, %{}) do
:ets.insert(table, {{module, :type, name, arity}, entry})
end

Map.get(contents, :mode, :elixir)
end

Expand Down
97 changes: 91 additions & 6 deletions lib/elixir/lib/module/types/of.ex
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,6 @@ defmodule Module.Types.Of do

This is expanded and validated by the compiler, so don't need to check the fields.
"""
# TODO: Type check the fields match the struct
def struct_instance(struct, args, expected, meta, stack, context, of_fun)
when is_atom(struct) do
{info, context} = struct_info(struct, :expr, meta, stack, context)
Expand All @@ -473,22 +472,81 @@ defmodule Module.Types.Of do
raise "expected #{inspect(struct)} to return struct metadata, but got none"
end

typed_fields = fetch_struct_type_descr(struct, stack)
defaults_by_field = struct_defaults_by_field(info)

# The compiler has already checked the keys are atoms and which ones are required.
{args_types, context} =
Enum.map_reduce(args, context, fn {key, value}, context when is_atom(key) ->
value_type =
case map_fetch_key(expected, key) do
{_, expected_value_type} -> expected_value_type
_ -> term()
end
typed_field_type = typed_field(typed_fields, key)

value_type = typed_field_type || expected_field_type(expected, key)

{type, context} = of_fun.(value, value_type, stack, context)

context =
cond do
typed_field_type == nil ->
context

# The compiler injects defaults into `args`. Don't warn for
# values that are exactly the defstruct default — those are
# not user-authored and would surface a noisy diagnostic at
# every struct construction site.
value == Map.get(defaults_by_field, key, :__no_default__) ->
context

compatible?(type, typed_field_type) ->
context

true ->
error =
{:badstructfield, struct, key, value, typed_field_type, type, context}

error(error, meta, stack, context)
end

{{key, type}, context}
end)

{closed_map([{:__struct__, atom([struct])} | args_types]), context}
end

defp typed_field(nil, _key), do: nil

defp typed_field(descr, key) do
case map_fetch_key(descr, key) do
{_optional?, type} -> type
_ -> nil
end
end

defp struct_defaults_by_field(info) do
Map.new(info, fn %{field: field, default: default} -> {field, default} end)
end

defp expected_field_type(expected, key) do
case map_fetch_key(expected, key) do
{_, type} -> type
_ -> term()
end
end

# Look up the `t/0` typespec Descr for `struct` from the parallel
# checker cache. The cache snapshot is written during
# `cache_from_module_map` (parallel_checker.ex) so it survives the
# teardown of the module's compile-time data tables.
#
# `@opaque t :: ...` is strict only inside the defining module; from
# any other module it is treated as `dynamic()` (i.e. nil here).
defp fetch_struct_type_descr(struct, stack) do
case Module.ParallelChecker.fetch_type(stack.cache, struct, :t, 0) do
{:type, descr} -> descr
{:opaque, descr} when struct == stack.module -> descr
_ -> nil
end
end

@doc """
Returns `__info__(:struct)` information about a struct.
"""
Expand Down Expand Up @@ -872,6 +930,33 @@ defmodule Module.Types.Of do
}
end

def format_diagnostic(
{:badstructfield, module, field, expr, expected_type, actual_type, context}
) do
traces = collect_traces(expr, context)

%{
details: %{typing_traces: traces},
message:
IO.iodata_to_binary([
"""
incompatible value for field #{inspect(field)} of struct #{inspect(module)}:

#{expr_to_string(expr) |> indent(4)}

got type:

#{to_quoted_string(actual_type) |> indent(4)}

but expected type:

#{to_quoted_string(expected_type) |> indent(4)}
""",
format_traces(traces)
])
}
end

defp dot_var?(expr) do
match?({{:., _, [var, _fun]}, _, _args} when is_var(var), expr)
end
Expand Down
Loading