Phases 1-7: Complete CljElixir compiler through Malli schema adapter
Bootstrap compiler (reader, analyzer, transformer, compiler, Mix plugin), core protocols (16 protocols for Map/List/Tuple/BitString), PersistentVector (bit-partitioned trie), domain tools (clojurify/elixirify), BEAM concurrency (receive, spawn, GenServer), control flow & macros (threading, try/catch, destructuring, defmacro with quasiquote/auto-gensym), and Malli schema adapter (m/=> specs, auto @type, recursive schemas, cross-references). 537 compiler tests + 55 Malli unit tests + 15 integration tests = 607 total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
defmodule CljElixir.Malli do
|
||||
@moduledoc """
|
||||
Converts Malli-style schema data to Elixir typespec AST.
|
||||
|
||||
Takes plain Elixir terms (atoms, lists, maps) representing Malli schemas
|
||||
and produces Elixir AST nodes suitable for `@spec` and `@type` attributes.
|
||||
|
||||
## Public API
|
||||
|
||||
* `spec_ast/2` - Generate `@spec` AST nodes from a function schema
|
||||
* `type_ast/2,3` - Generate `@type` AST nodes from a type schema
|
||||
* `schema_to_typespec/2` - Convert a schema to its typespec AST (the type part only)
|
||||
"""
|
||||
|
||||
# Atoms that need quoted syntax in source but are valid at runtime
|
||||
@arrow :"=>"
|
||||
@optional_marker :"?"
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Generates a list of `@spec` AST nodes for the given function name and schema.
|
||||
|
||||
`schema` is either `[:=> ...]` for a single-arity function or
|
||||
`[:function ...]` for a multi-arity function.
|
||||
|
||||
Returns a list because `:function` schemas and optional params
|
||||
can produce multiple `@spec` entries.
|
||||
"""
|
||||
@spec spec_ast(atom(), list(), keyword()) :: list()
|
||||
def spec_ast(fun_name, schema, opts \\ [])
|
||||
|
||||
def spec_ast(fun_name, [:function | clauses], opts) do
|
||||
Enum.flat_map(clauses, fn clause -> spec_ast(fun_name, clause, opts) end)
|
||||
end
|
||||
|
||||
def spec_ast(fun_name, [@arrow, [:cat | param_schemas], return_schema], opts) do
|
||||
ret_ast = schema_to_typespec(return_schema, opts)
|
||||
param_groups = expand_optional_params(param_schemas)
|
||||
|
||||
Enum.map(param_groups, fn params ->
|
||||
param_asts = Enum.map(params, &schema_to_typespec(&1, opts))
|
||||
wrap_spec(fun_name, param_asts, ret_ast)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a `@type` AST node for the given type name and schema.
|
||||
|
||||
Accepts an optional `opts` keyword list with `:known_types` for cross-references.
|
||||
For schemas with a `:registry` key, generates multiple types from the registry.
|
||||
"""
|
||||
@spec type_ast(atom(), list(), keyword()) :: tuple() | list()
|
||||
def type_ast(type_name, schema, opts \\ [])
|
||||
|
||||
def type_ast(type_name, [:schema, %{registry: registry}, ref_schema], opts) do
|
||||
type_ast_registry(type_name, [:schema, %{registry: registry}, ref_schema], registry, opts)
|
||||
end
|
||||
|
||||
def type_ast(type_name, schema, opts) do
|
||||
type_body = schema_to_typespec(schema, opts)
|
||||
wrap_type(type_name, type_body)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a list of `@type` AST nodes, one for each entry in the registry.
|
||||
|
||||
`registry_types` is a map of `{name_atom => schema}` pairs or a list of
|
||||
`{name_atom, schema}` tuples.
|
||||
"""
|
||||
def type_ast_registry(_type_name, [:schema, %{registry: _}, _ref], registry_types, opts) when is_map(registry_types) do
|
||||
Enum.map(registry_types, fn {name, schema} ->
|
||||
clean_name = clean_registry_name(name)
|
||||
body = schema_to_typespec(schema, Keyword.put(opts, :registry, registry_types))
|
||||
wrap_type(clean_name, body)
|
||||
end)
|
||||
end
|
||||
|
||||
def type_ast_registry(_type_name, [:schema, %{registry: _}, _ref], registry_types, opts) when is_list(registry_types) do
|
||||
Enum.map(registry_types, fn {name, schema} ->
|
||||
clean_name = clean_registry_name(name)
|
||||
body = schema_to_typespec(schema, Keyword.put(opts, :registry, Map.new(registry_types)))
|
||||
wrap_type(clean_name, body)
|
||||
end)
|
||||
end
|
||||
|
||||
# ── schema_to_typespec ──────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Converts a schema to its typespec AST representation (the type part,
|
||||
not the `@type` wrapper).
|
||||
|
||||
## Options
|
||||
|
||||
* `:known_types` - map of `%{"User" => :user, ...}` for cross-schema references
|
||||
* `:registry` - map of registry types for resolving `:ref` references
|
||||
"""
|
||||
@spec schema_to_typespec(term(), keyword()) :: term()
|
||||
def schema_to_typespec(schema, opts \\ [])
|
||||
|
||||
# ── Primitives ──────────────────────────────────────────────────────
|
||||
|
||||
def schema_to_typespec(:string, _opts), do: string_t_ast()
|
||||
def schema_to_typespec(:int, _opts), do: {:integer, [], []}
|
||||
def schema_to_typespec(:integer, _opts), do: {:integer, [], []}
|
||||
def schema_to_typespec(:float, _opts), do: {:float, [], []}
|
||||
def schema_to_typespec(:number, _opts), do: {:number, [], []}
|
||||
def schema_to_typespec(:boolean, _opts), do: {:boolean, [], []}
|
||||
def schema_to_typespec(:atom, _opts), do: {:atom, [], []}
|
||||
def schema_to_typespec(:keyword, _opts), do: {:atom, [], []}
|
||||
def schema_to_typespec(:any, _opts), do: {:any, [], []}
|
||||
def schema_to_typespec(:nil, _opts), do: nil
|
||||
def schema_to_typespec(:pid, _opts), do: {:pid, [], []}
|
||||
def schema_to_typespec(:port, _opts), do: {:port, [], []}
|
||||
def schema_to_typespec(:reference, _opts), do: {:reference, [], []}
|
||||
def schema_to_typespec(:"pos-int", _opts), do: {:pos_integer, [], []}
|
||||
def schema_to_typespec(:"neg-int", _opts), do: {:neg_integer, [], []}
|
||||
def schema_to_typespec(:"nat-int", _opts), do: {:non_neg_integer, [], []}
|
||||
|
||||
# ── Schema references (string keys) ────────────────────────────────
|
||||
|
||||
def schema_to_typespec(name, opts) when is_binary(name) do
|
||||
known = Keyword.get(opts, :known_types, %{})
|
||||
|
||||
case Map.fetch(known, name) do
|
||||
{:ok, type_name} -> {type_name, [], []}
|
||||
:error -> {:any, [], []}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Literal values (atoms that aren't schema keywords) ──────────────
|
||||
|
||||
def schema_to_typespec(atom, _opts) when is_atom(atom), do: atom
|
||||
def schema_to_typespec(int, _opts) when is_integer(int), do: int
|
||||
|
||||
# ── Compound and container types (list schemas) ─────────────────────
|
||||
|
||||
def schema_to_typespec([head | _rest] = schema, opts) do
|
||||
convert_list_schema(head, schema, opts)
|
||||
end
|
||||
|
||||
# ── Fallback ───────────────────────────────────────────────────────
|
||||
|
||||
def schema_to_typespec(_, _opts), do: {:any, [], []}
|
||||
|
||||
# ── List schema dispatch ────────────────────────────────────────────
|
||||
|
||||
defp convert_list_schema(:or, [_ | types], opts) do
|
||||
type_asts = Enum.map(types, &schema_to_typespec(&1, opts))
|
||||
right_assoc_union(type_asts)
|
||||
end
|
||||
|
||||
defp convert_list_schema(:and, [_ | schemas], opts) do
|
||||
resolve_and_type(schemas, opts)
|
||||
end
|
||||
|
||||
defp convert_list_schema(:maybe, [:maybe, schema], opts) do
|
||||
inner = schema_to_typespec(schema, opts)
|
||||
{:|, [], [inner, nil]}
|
||||
end
|
||||
|
||||
defp convert_list_schema(:enum, [_ | values], _opts) do
|
||||
right_assoc_union(values)
|
||||
end
|
||||
|
||||
defp convert_list_schema(:=, [:=, value], _opts), do: value
|
||||
|
||||
defp convert_list_schema(:map, [_ | field_specs], opts) do
|
||||
fields =
|
||||
Enum.map(field_specs, fn
|
||||
[name, {:optional, true}, schema] ->
|
||||
{name, schema_to_typespec(schema, opts)}
|
||||
|
||||
[name, schema] ->
|
||||
{name, schema_to_typespec(schema, opts)}
|
||||
end)
|
||||
|
||||
{:%{}, [], fields}
|
||||
end
|
||||
|
||||
defp convert_list_schema(:"map-of", [_, key_schema, val_schema], opts) do
|
||||
key_ast = schema_to_typespec(key_schema, opts)
|
||||
val_ast = schema_to_typespec(val_schema, opts)
|
||||
{:%{}, [], [{{:optional, [], [key_ast]}, val_ast}]}
|
||||
end
|
||||
|
||||
defp convert_list_schema(:list, [:list, elem_schema], opts) do
|
||||
[schema_to_typespec(elem_schema, opts)]
|
||||
end
|
||||
|
||||
defp convert_list_schema(:vector, _schema, _opts) do
|
||||
persistent_vector_t_ast()
|
||||
end
|
||||
|
||||
defp convert_list_schema(:set, _schema, _opts) do
|
||||
mapset_t_ast()
|
||||
end
|
||||
|
||||
defp convert_list_schema(:tuple, [_ | elem_schemas], opts) do
|
||||
elems = Enum.map(elem_schemas, &schema_to_typespec(&1, opts))
|
||||
{:{}, [], elems}
|
||||
end
|
||||
|
||||
defp convert_list_schema(:ref, [:ref, name], opts) do
|
||||
clean = clean_registry_name(name)
|
||||
registry = Keyword.get(opts, :registry, %{})
|
||||
|
||||
if Map.has_key?(registry, name) or Map.has_key?(registry, clean) do
|
||||
{clean, [], []}
|
||||
else
|
||||
known = Keyword.get(opts, :known_types, %{})
|
||||
|
||||
case Map.fetch(known, name) do
|
||||
{:ok, type_name} -> {type_name, [], []}
|
||||
:error -> {clean, [], []}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp convert_list_schema(:schema, [:schema, %{registry: registry}, ref_schema], opts) do
|
||||
merged_opts = Keyword.put(opts, :registry, registry)
|
||||
schema_to_typespec(ref_schema, merged_opts)
|
||||
end
|
||||
|
||||
defp convert_list_schema(:>, _, _opts), do: {:any, [], []}
|
||||
defp convert_list_schema(:>=, _, _opts), do: {:any, [], []}
|
||||
defp convert_list_schema(:<, _, _opts), do: {:any, [], []}
|
||||
defp convert_list_schema(:<=, _, _opts), do: {:any, [], []}
|
||||
|
||||
defp convert_list_schema(head, schema, opts) when head == @arrow do
|
||||
[@arrow, [:cat | params], ret] = schema
|
||||
param_asts = Enum.map(params, &schema_to_typespec(&1, opts))
|
||||
ret_ast = schema_to_typespec(ret, opts)
|
||||
[{:->, [], [param_asts, ret_ast]}]
|
||||
end
|
||||
|
||||
defp convert_list_schema(_, _, _opts), do: {:any, [], []}
|
||||
|
||||
# ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
defp string_t_ast do
|
||||
{{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
|
||||
end
|
||||
|
||||
defp mapset_t_ast do
|
||||
{{:., [], [{:__aliases__, [alias: false], [:MapSet]}, :t]}, [], []}
|
||||
end
|
||||
|
||||
defp persistent_vector_t_ast do
|
||||
{{:., [], [{:__aliases__, [alias: false], [:CljElixir, :PersistentVector]}, :t]}, [], []}
|
||||
end
|
||||
|
||||
defp right_assoc_union([single]), do: single
|
||||
|
||||
defp right_assoc_union([first | rest]) do
|
||||
{:|, [], [first, right_assoc_union(rest)]}
|
||||
end
|
||||
|
||||
defp wrap_spec(fun_name, param_asts, ret_ast) do
|
||||
{:@, [], [
|
||||
{:spec, [], [
|
||||
{:"::", [], [
|
||||
{fun_name, [], param_asts},
|
||||
ret_ast
|
||||
]}
|
||||
]}
|
||||
]}
|
||||
end
|
||||
|
||||
defp wrap_type(type_name, body_ast) do
|
||||
{:@, [], [
|
||||
{:type, [], [
|
||||
{:"::", [], [
|
||||
{type_name, [], []},
|
||||
body_ast
|
||||
]}
|
||||
]}
|
||||
]}
|
||||
end
|
||||
|
||||
defp clean_registry_name(name) when is_atom(name) do
|
||||
name_str = Atom.to_string(name)
|
||||
|
||||
cleaned =
|
||||
name_str
|
||||
|> String.replace(~r/^(Elixir\.CljElixir\.|::)/, "")
|
||||
|
||||
String.to_atom(cleaned)
|
||||
end
|
||||
|
||||
defp clean_registry_name(name), do: name
|
||||
|
||||
# Expand optional params into all param combinations.
|
||||
# E.g., [:string, [:"?", :string], [:"?", :int]] produces:
|
||||
# [[:string], [:string, :string], [:string, :string, :int]]
|
||||
defp expand_optional_params(param_schemas) do
|
||||
{required, optionals} = split_required_optional(param_schemas)
|
||||
|
||||
for n <- 0..length(optionals) do
|
||||
required ++ Enum.take(optionals, n)
|
||||
end
|
||||
end
|
||||
|
||||
defp split_required_optional(params) do
|
||||
split_required_optional(params, [])
|
||||
end
|
||||
|
||||
defp split_required_optional([[@optional_marker, schema] | rest], req_acc) do
|
||||
optionals = [schema | extract_optionals(rest)]
|
||||
{Enum.reverse(req_acc), optionals}
|
||||
end
|
||||
|
||||
defp split_required_optional([param | rest], req_acc) do
|
||||
split_required_optional(rest, [param | req_acc])
|
||||
end
|
||||
|
||||
defp split_required_optional([], req_acc) do
|
||||
{Enum.reverse(req_acc), []}
|
||||
end
|
||||
|
||||
defp extract_optionals([[@optional_marker, schema] | rest]) do
|
||||
[schema | extract_optionals(rest)]
|
||||
end
|
||||
|
||||
defp extract_optionals([_ | rest]), do: extract_optionals(rest)
|
||||
defp extract_optionals([]), do: []
|
||||
|
||||
# Resolve :and types — extract most specific expressible type.
|
||||
# Special cases: [:and :int [:> 0]] -> pos_integer()
|
||||
# [:and :int [:>= 0]] -> non_neg_integer()
|
||||
defp resolve_and_type(schemas, opts) do
|
||||
base_types = Enum.filter(schemas, &recognized_schema?/1)
|
||||
constraints = Enum.filter(schemas, &constraint?/1)
|
||||
|
||||
case {base_types, constraints} do
|
||||
{[:int], [[:>, 0]]} -> {:pos_integer, [], []}
|
||||
{[:integer], [[:>, 0]]} -> {:pos_integer, [], []}
|
||||
{[:int], [[:>=, 0]]} -> {:non_neg_integer, [], []}
|
||||
{[:integer], [[:>=, 0]]} -> {:non_neg_integer, [], []}
|
||||
{[base | _], _} -> schema_to_typespec(base, opts)
|
||||
{[], _} -> {:any, [], []}
|
||||
end
|
||||
end
|
||||
|
||||
@primitive_types [
|
||||
:string, :int, :integer, :float, :number, :boolean, :atom, :keyword,
|
||||
:any, :nil, :pid, :port, :reference, :"pos-int", :"neg-int", :"nat-int"
|
||||
]
|
||||
|
||||
@compound_heads [:or, :and, :maybe, :enum, :=, :map, :"map-of",
|
||||
:list, :vector, :set, :tuple, :ref, :schema]
|
||||
|
||||
defp recognized_schema?(schema) when is_atom(schema) do
|
||||
schema in @primitive_types
|
||||
end
|
||||
|
||||
defp recognized_schema?([head | _]) when is_atom(head) do
|
||||
head in @compound_heads or head == @arrow
|
||||
end
|
||||
|
||||
defp recognized_schema?(_), do: false
|
||||
|
||||
defp constraint?([:>, _]), do: true
|
||||
defp constraint?([:>=, _]), do: true
|
||||
defp constraint?([:<, _]), do: true
|
||||
defp constraint?([:<=, _]), do: true
|
||||
defp constraint?(_), do: false
|
||||
end
|
||||
Reference in New Issue
Block a user