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,501 @@
|
||||
defmodule CljElixir.MalliTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias CljElixir.Malli
|
||||
|
||||
# Atoms that need quoted syntax in Elixir source
|
||||
@arrow :"=>"
|
||||
@opt :"?"
|
||||
|
||||
# ── Helper ──────────────────────────────────────────────────────────
|
||||
|
||||
defp string_t do
|
||||
{{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
|
||||
end
|
||||
|
||||
defp mapset_t do
|
||||
{{:., [], [{:__aliases__, [alias: false], [:MapSet]}, :t]}, [], []}
|
||||
end
|
||||
|
||||
defp pv_t do
|
||||
{{:., [], [{:__aliases__, [alias: false], [:CljElixir, :PersistentVector]}, :t]}, [], []}
|
||||
end
|
||||
|
||||
# ── Primitive types ─────────────────────────────────────────────────
|
||||
|
||||
describe "primitive types" do
|
||||
test "string" do
|
||||
assert string_t() == Malli.schema_to_typespec(:string)
|
||||
end
|
||||
|
||||
test "int" do
|
||||
assert {:integer, [], []} = Malli.schema_to_typespec(:int)
|
||||
end
|
||||
|
||||
test "integer" do
|
||||
assert {:integer, [], []} = Malli.schema_to_typespec(:integer)
|
||||
end
|
||||
|
||||
test "float" do
|
||||
assert {:float, [], []} = Malli.schema_to_typespec(:float)
|
||||
end
|
||||
|
||||
test "number" do
|
||||
assert {:number, [], []} = Malli.schema_to_typespec(:number)
|
||||
end
|
||||
|
||||
test "boolean" do
|
||||
assert {:boolean, [], []} = Malli.schema_to_typespec(:boolean)
|
||||
end
|
||||
|
||||
test "atom" do
|
||||
assert {:atom, [], []} = Malli.schema_to_typespec(:atom)
|
||||
end
|
||||
|
||||
test "keyword" do
|
||||
assert {:atom, [], []} = Malli.schema_to_typespec(:keyword)
|
||||
end
|
||||
|
||||
test "any" do
|
||||
assert {:any, [], []} = Malli.schema_to_typespec(:any)
|
||||
end
|
||||
|
||||
test "nil" do
|
||||
assert nil == Malli.schema_to_typespec(:nil)
|
||||
end
|
||||
|
||||
test "pid" do
|
||||
assert {:pid, [], []} = Malli.schema_to_typespec(:pid)
|
||||
end
|
||||
|
||||
test "port" do
|
||||
assert {:port, [], []} = Malli.schema_to_typespec(:port)
|
||||
end
|
||||
|
||||
test "reference" do
|
||||
assert {:reference, [], []} = Malli.schema_to_typespec(:reference)
|
||||
end
|
||||
|
||||
test "pos-int" do
|
||||
assert {:pos_integer, [], []} = Malli.schema_to_typespec(:"pos-int")
|
||||
end
|
||||
|
||||
test "neg-int" do
|
||||
assert {:neg_integer, [], []} = Malli.schema_to_typespec(:"neg-int")
|
||||
end
|
||||
|
||||
test "nat-int" do
|
||||
assert {:non_neg_integer, [], []} = Malli.schema_to_typespec(:"nat-int")
|
||||
end
|
||||
end
|
||||
|
||||
# ── Compound types ─────────────────────────────────────────────────
|
||||
|
||||
describe "compound types" do
|
||||
test "or with two types" do
|
||||
ast = Malli.schema_to_typespec([:or, :int, :string])
|
||||
expected_string = string_t()
|
||||
assert {:|, [], [{:integer, [], []}, ^expected_string]} = ast
|
||||
end
|
||||
|
||||
test "or with three types (right-associative)" do
|
||||
ast = Malli.schema_to_typespec([:or, :int, :string, :boolean])
|
||||
assert {:|, [], [{:integer, [], []}, {:|, [], [_, {:boolean, [], []}]}]} = ast
|
||||
end
|
||||
|
||||
test "maybe type" do
|
||||
ast = Malli.schema_to_typespec([:maybe, :string])
|
||||
expected_string = string_t()
|
||||
assert {:|, [], [^expected_string, nil]} = ast
|
||||
end
|
||||
|
||||
test "enum type" do
|
||||
ast = Malli.schema_to_typespec([:enum, :a, :b, :c])
|
||||
assert {:|, [], [:a, {:|, [], [:b, :c]}]} = ast
|
||||
end
|
||||
|
||||
test "enum with single value" do
|
||||
assert :a = Malli.schema_to_typespec([:enum, :a])
|
||||
end
|
||||
|
||||
test "= literal" do
|
||||
assert :hello = Malli.schema_to_typespec([:=, :hello])
|
||||
assert 42 = Malli.schema_to_typespec([:=, 42])
|
||||
end
|
||||
|
||||
test "and with base type and constraint (general)" do
|
||||
ast = Malli.schema_to_typespec([:and, :int, [:<, 100]])
|
||||
assert {:integer, [], []} = ast
|
||||
end
|
||||
|
||||
test "and :int [:> 0] produces pos_integer()" do
|
||||
ast = Malli.schema_to_typespec([:and, :int, [:>, 0]])
|
||||
assert {:pos_integer, [], []} = ast
|
||||
end
|
||||
|
||||
test "and :int [:>= 0] produces non_neg_integer()" do
|
||||
ast = Malli.schema_to_typespec([:and, :int, [:>=, 0]])
|
||||
assert {:non_neg_integer, [], []} = ast
|
||||
end
|
||||
end
|
||||
|
||||
# ── Container types ────────────────────────────────────────────────
|
||||
|
||||
describe "container types" do
|
||||
test "map with fields" do
|
||||
ast = Malli.schema_to_typespec([:map, [:name, :string], [:age, :int]])
|
||||
assert {:%{}, [], kv} = ast
|
||||
assert Keyword.has_key?(kv, :name)
|
||||
assert Keyword.has_key?(kv, :age)
|
||||
expected_string = string_t()
|
||||
assert ^expected_string = Keyword.get(kv, :name)
|
||||
assert {:integer, [], []} = Keyword.get(kv, :age)
|
||||
end
|
||||
|
||||
test "map with optional field" do
|
||||
ast = Malli.schema_to_typespec([:map, [:name, :string], [:email, {:optional, true}, :string]])
|
||||
assert {:%{}, [], kv} = ast
|
||||
assert Keyword.has_key?(kv, :name)
|
||||
assert Keyword.has_key?(kv, :email)
|
||||
end
|
||||
|
||||
test "map-of" do
|
||||
ast = Malli.schema_to_typespec([:"map-of", :string, :int])
|
||||
assert {:%{}, [], [optional_entry]} = ast
|
||||
assert {{:optional, [], [_key_t]}, {:integer, [], []}} = optional_entry
|
||||
end
|
||||
|
||||
test "list" do
|
||||
ast = Malli.schema_to_typespec([:list, :int])
|
||||
assert [{:integer, [], []}] = ast
|
||||
end
|
||||
|
||||
test "vector" do
|
||||
ast = Malli.schema_to_typespec([:vector, :int])
|
||||
assert ^ast = pv_t()
|
||||
end
|
||||
|
||||
test "set" do
|
||||
ast = Malli.schema_to_typespec([:set, :int])
|
||||
assert ^ast = mapset_t()
|
||||
end
|
||||
|
||||
test "tuple" do
|
||||
ast = Malli.schema_to_typespec([:tuple, :int, :string])
|
||||
expected_string = string_t()
|
||||
assert {:{}, [], [{:integer, [], []}, ^expected_string]} = ast
|
||||
end
|
||||
|
||||
test "tuple with three elements" do
|
||||
ast = Malli.schema_to_typespec([:tuple, :int, :string, :boolean])
|
||||
assert {:{}, [], [{:integer, [], []}, _, {:boolean, [], []}]} = ast
|
||||
end
|
||||
end
|
||||
|
||||
# ── Function specs ─────────────────────────────────────────────────
|
||||
|
||||
describe "function specs" do
|
||||
test "simple function spec" do
|
||||
specs = Malli.spec_ast(:hello, [@arrow, [:cat, :string], :string])
|
||||
assert length(specs) == 1
|
||||
[{:@, [], [{:spec, [], [spec_body]}]}] = specs
|
||||
assert {:"::", [], [{:hello, [], [_arg]}, _ret]} = spec_body
|
||||
end
|
||||
|
||||
test "function with two params" do
|
||||
specs = Malli.spec_ast(:add, [@arrow, [:cat, :int, :int], :int])
|
||||
assert length(specs) == 1
|
||||
[{:@, [], [{:spec, [], [{:"::", [], [{:add, [], args}, _ret]}]}]}] = specs
|
||||
assert length(args) == 2
|
||||
end
|
||||
|
||||
test "function with optional param produces two specs" do
|
||||
specs = Malli.spec_ast(:greet, [@arrow, [:cat, :string, [@opt, :string]], :string])
|
||||
assert length(specs) == 2
|
||||
|
||||
arities = Enum.map(specs, fn
|
||||
{:@, [], [{:spec, [], [{:"::", [], [{:greet, [], args}, _ret]}]}]} ->
|
||||
length(args)
|
||||
end)
|
||||
|
||||
assert Enum.sort(arities) == [1, 2]
|
||||
end
|
||||
|
||||
test "function with multiple optional params" do
|
||||
specs = Malli.spec_ast(:f, [@arrow, [:cat, :int, [@opt, :string], [@opt, :boolean]], :any])
|
||||
assert length(specs) == 3
|
||||
|
||||
arities = Enum.map(specs, fn
|
||||
{:@, [], [{:spec, [], [{:"::", [], [{:f, [], args}, _ret]}]}]} ->
|
||||
length(args)
|
||||
end)
|
||||
|
||||
assert Enum.sort(arities) == [1, 2, 3]
|
||||
end
|
||||
|
||||
test "multi-arity function via :function" do
|
||||
specs =
|
||||
Malli.spec_ast(:greet, [
|
||||
:function,
|
||||
[@arrow, [:cat, :string], :string],
|
||||
[@arrow, [:cat, :string, :string], :string]
|
||||
])
|
||||
|
||||
assert length(specs) == 2
|
||||
|
||||
arities = Enum.map(specs, fn
|
||||
{:@, [], [{:spec, [], [{:"::", [], [{:greet, [], args}, _ret]}]}]} ->
|
||||
length(args)
|
||||
end)
|
||||
|
||||
assert Enum.sort(arities) == [1, 2]
|
||||
end
|
||||
end
|
||||
|
||||
# ── Type generation ────────────────────────────────────────────────
|
||||
|
||||
describe "type generation" do
|
||||
test "named type from map schema" do
|
||||
ast = Malli.type_ast(:user, [:map, [:name, :string], [:age, :int]])
|
||||
assert {:@, [], [{:type, [], [{:"::", [], [{:user, [], []}, _map_type]}]}]} = ast
|
||||
end
|
||||
|
||||
test "named type from primitive" do
|
||||
ast = Malli.type_ast(:name, :string)
|
||||
expected_string = string_t()
|
||||
assert {:@, [], [{:type, [], [{:"::", [], [{:name, [], []}, ^expected_string]}]}]} = ast
|
||||
end
|
||||
end
|
||||
|
||||
# ── Schema references ─────────────────────────────────────────────
|
||||
|
||||
describe "schema references" do
|
||||
test "known type reference" do
|
||||
ast = Malli.schema_to_typespec("User", known_types: %{"User" => :user})
|
||||
assert {:user, [], []} = ast
|
||||
end
|
||||
|
||||
test "unknown string reference falls back to any()" do
|
||||
ast = Malli.schema_to_typespec("Unknown")
|
||||
assert {:any, [], []} = ast
|
||||
end
|
||||
end
|
||||
|
||||
# ── Recursive types ───────────────────────────────────────────────
|
||||
|
||||
describe "recursive types" do
|
||||
test "ref produces type call" do
|
||||
ast = Malli.schema_to_typespec([:ref, :tree], registry: %{tree: [:or, :int, :nil]})
|
||||
assert {:tree, [], []} = ast
|
||||
end
|
||||
|
||||
test "schema with registry via type_ast/2 dispatches to type_ast/3" do
|
||||
schema = [
|
||||
:schema,
|
||||
%{registry: %{tree: [:or, :int, [:tuple, [:ref, :tree], [:ref, :tree]]]}},
|
||||
[:ref, :tree]
|
||||
]
|
||||
|
||||
types = Malli.type_ast(:tree, schema)
|
||||
assert is_list(types)
|
||||
assert length(types) >= 1
|
||||
|
||||
[{:@, [], [{:type, [], [{:"::", [], [{:tree, [], []}, _body]}]}]}] = types
|
||||
end
|
||||
|
||||
test "schema with registry via type_ast/3 with list of pairs" do
|
||||
registry = [{:tree, [:or, :int, [:tuple, [:ref, :tree], [:ref, :tree]]]}]
|
||||
schema = [:schema, %{registry: Map.new(registry)}, [:ref, :tree]]
|
||||
|
||||
types = Malli.type_ast(:tree, schema, registry)
|
||||
assert is_list(types)
|
||||
assert length(types) == 1
|
||||
end
|
||||
end
|
||||
|
||||
# ── Compilation smoke tests ───────────────────────────────────────
|
||||
|
||||
describe "compilation smoke test" do
|
||||
test "generated spec compiles in a module" do
|
||||
spec_asts = Malli.spec_ast(:hello, [@arrow, [:cat, :string], :string])
|
||||
|
||||
fun_ast =
|
||||
{:def, [], [
|
||||
{:hello, [], [{:name, [], nil}]},
|
||||
[do: {:name, [], nil}]
|
||||
]}
|
||||
|
||||
module_body = spec_asts ++ [fun_ast]
|
||||
block = {:__block__, [], module_body}
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest1]}, [do: block]]}
|
||||
|
||||
assert [{MalliSmokeTest1, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest1)
|
||||
:code.delete(MalliSmokeTest1)
|
||||
end
|
||||
|
||||
test "generated type compiles in a module" do
|
||||
type_ast = Malli.type_ast(:user, [:map, [:name, :string], [:age, :int]])
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest2]}, [do: type_ast]]}
|
||||
|
||||
assert [{MalliSmokeTest2, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest2)
|
||||
:code.delete(MalliSmokeTest2)
|
||||
end
|
||||
|
||||
test "multi-arity spec compiles" do
|
||||
specs =
|
||||
Malli.spec_ast(:greet, [
|
||||
:function,
|
||||
[@arrow, [:cat, :string], :string],
|
||||
[@arrow, [:cat, :string, :string], :string]
|
||||
])
|
||||
|
||||
fun1 =
|
||||
{:def, [], [
|
||||
{:greet, [], [{:name, [], nil}]},
|
||||
[do: {:name, [], nil}]
|
||||
]}
|
||||
|
||||
fun2 =
|
||||
{:def, [], [
|
||||
{:greet, [], [{:greeting, [], nil}, {:name, [], nil}]},
|
||||
[do: {:name, [], nil}]
|
||||
]}
|
||||
|
||||
module_body = specs ++ [fun1, fun2]
|
||||
block = {:__block__, [], module_body}
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest3]}, [do: block]]}
|
||||
|
||||
assert [{MalliSmokeTest3, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest3)
|
||||
:code.delete(MalliSmokeTest3)
|
||||
end
|
||||
|
||||
test "map-of type compiles" do
|
||||
type_ast = Malli.type_ast(:counts, [:"map-of", :string, :int])
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest4]}, [do: type_ast]]}
|
||||
|
||||
assert [{MalliSmokeTest4, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest4)
|
||||
:code.delete(MalliSmokeTest4)
|
||||
end
|
||||
|
||||
test "tuple type compiles" do
|
||||
type_ast = Malli.type_ast(:point, [:tuple, :int, :int])
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest5]}, [do: type_ast]]}
|
||||
|
||||
assert [{MalliSmokeTest5, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest5)
|
||||
:code.delete(MalliSmokeTest5)
|
||||
end
|
||||
|
||||
test "list type compiles" do
|
||||
type_ast = Malli.type_ast(:names, [:list, :string])
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest6]}, [do: type_ast]]}
|
||||
|
||||
assert [{MalliSmokeTest6, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest6)
|
||||
:code.delete(MalliSmokeTest6)
|
||||
end
|
||||
|
||||
test "or type compiles" do
|
||||
type_ast = Malli.type_ast(:string_or_int, [:or, :string, :int])
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest7]}, [do: type_ast]]}
|
||||
|
||||
assert [{MalliSmokeTest7, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest7)
|
||||
:code.delete(MalliSmokeTest7)
|
||||
end
|
||||
|
||||
test "maybe type compiles" do
|
||||
type_ast = Malli.type_ast(:opt_string, [:maybe, :string])
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest8]}, [do: type_ast]]}
|
||||
|
||||
assert [{MalliSmokeTest8, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest8)
|
||||
:code.delete(MalliSmokeTest8)
|
||||
end
|
||||
|
||||
test "recursive type compiles" do
|
||||
schema = [
|
||||
:schema,
|
||||
%{registry: %{tree: [:or, :int, [:tuple, [:ref, :tree], [:ref, :tree]]]}},
|
||||
[:ref, :tree]
|
||||
]
|
||||
|
||||
types = Malli.type_ast(:tree, schema)
|
||||
block = {:__block__, [], types}
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest9]}, [do: block]]}
|
||||
|
||||
assert [{MalliSmokeTest9, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest9)
|
||||
:code.delete(MalliSmokeTest9)
|
||||
end
|
||||
|
||||
test "optional params spec compiles" do
|
||||
specs =
|
||||
Malli.spec_ast(:greet, [@arrow, [:cat, :string, [@opt, :string]], :string])
|
||||
|
||||
fun1 =
|
||||
{:def, [], [
|
||||
{:greet, [], [{:name, [], nil}]},
|
||||
[do: {:name, [], nil}]
|
||||
]}
|
||||
|
||||
fun2 =
|
||||
{:def, [], [
|
||||
{:greet, [], [{:name, [], nil}, {:greeting, [], nil}]},
|
||||
[do: {:name, [], nil}]
|
||||
]}
|
||||
|
||||
module_body = specs ++ [fun1, fun2]
|
||||
block = {:__block__, [], module_body}
|
||||
|
||||
module_ast =
|
||||
{:defmodule, [context: Elixir],
|
||||
[{:__aliases__, [alias: false], [:MalliSmokeTest10]}, [do: block]]}
|
||||
|
||||
assert [{MalliSmokeTest10, _binary}] = Code.compile_quoted(module_ast)
|
||||
after
|
||||
:code.purge(MalliSmokeTest10)
|
||||
:code.delete(MalliSmokeTest10)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user