Files
Adam d8719b6d48 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>
2026-03-08 10:38:22 -04:00

502 lines
15 KiB
Elixir

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