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>
502 lines
15 KiB
Elixir
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
|