commit d8719b6d4884d111c80779bfc349a8c06156e738 Author: Adam Date: Sun Mar 8 10:38:22 2026 -0400 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 diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ccab38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/_build/ +/cover/ +/deps/ +/doc/ +/.fetch +erl_crash.dump +*.ez +*.beam +/tmp/ +.elixir_ls/ diff --git a/lib/clj_elixir.ex b/lib/clj_elixir.ex new file mode 100644 index 0000000..fdc6389 --- /dev/null +++ b/lib/clj_elixir.ex @@ -0,0 +1,71 @@ +defmodule CljElixir do + @moduledoc "CljElixir: Clojure-syntax language for the BEAM" + + @doc """ + Compile a CljElixir source string to Elixir AST. + + Returns `{:ok, elixir_ast}` on success, or `{:error, diagnostics}` on failure. + + ## Options + + * `:file` - the file path for error reporting (default: `"nofile"`) + + ## Examples + + iex> CljElixir.compile_string("(+ 1 2)") + {:ok, {:+, [line: 1], [1, 2]}} + + """ + @spec compile_string(String.t(), keyword()) :: {:ok, term()} | {:error, list()} + def compile_string(source, opts \\ []) do + CljElixir.Compiler.compile_string(source, opts) + end + + @doc """ + Compile a `.clje` file to Elixir AST. + + Reads the file and delegates to `compile_string/2` with the `:file` option set. + + Returns `{:ok, elixir_ast}` on success, or `{:error, diagnostics}` on failure. + Raises `File.Error` if the file cannot be read. + """ + @spec compile_file(Path.t(), keyword()) :: {:ok, term()} | {:error, list()} + def compile_file(path, opts \\ []) do + CljElixir.Compiler.compile_file(path, opts) + end + + @doc """ + Compile and evaluate a CljElixir source string. + + Returns `{:ok, result, bindings}` on success, or `{:error, diagnostics}` on failure. + + ## Options + + * `:file` - the file path for error reporting (default: `"nofile"`) + * `:bindings` - variable bindings for evaluation (default: `[]`) + * `:env` - the macro environment for evaluation (default: `__ENV__`) + + ## Examples + + iex> CljElixir.eval_string("(+ 1 2)") + {:ok, 3, []} + + """ + @spec eval_string(String.t(), keyword()) :: {:ok, term(), keyword()} | {:error, list()} + def eval_string(source, opts \\ []) do + CljElixir.Compiler.eval_string(source, opts) + end + + @doc """ + Compile and evaluate a `.clje` file. + + Reads the file and delegates to `eval_string/2` with the `:file` option set. + + Returns `{:ok, result, bindings}` on success, or `{:error, diagnostics}` on failure. + Raises `File.Error` if the file cannot be read. + """ + @spec eval_file(Path.t(), keyword()) :: {:ok, term(), keyword()} | {:error, list()} + def eval_file(path, opts \\ []) do + CljElixir.Compiler.eval_file(path, opts) + end +end diff --git a/lib/clj_elixir/analyzer.ex b/lib/clj_elixir/analyzer.ex new file mode 100644 index 0000000..35ebcd0 --- /dev/null +++ b/lib/clj_elixir/analyzer.ex @@ -0,0 +1,626 @@ +defmodule CljElixir.Analyzer do + @moduledoc """ + AST analyzer and validator for CljElixir. + + Performs lightweight static analysis on CljElixir AST forms (output of the Reader) + before they are passed to the Transformer. Catches common structural errors early + with clear diagnostic messages. + + ## Validations + + 1. **Special form arity** - `defmodule` needs name + body, `let` needs a vector + with an even number of binding pairs, `if` needs 2-3 args, `case` needs a + subject + even pattern/body pairs, `cond` needs even pairs, `loop` needs a + vector with even binding pairs. + + 2. **Map literal validation** - Maps must have an even number of forms (key-value pairs). + + 3. **`recur` position** - `recur` must appear in tail position. In `if`/`case`/`cond`, + the tail position is the last expression of each branch. In `let`/`do`, the tail + position is the last expression. + + 4. **Nested `recur`** - `recur` inside a nested `loop` should only refer to the + innermost loop, not an outer one. + + ## Return Value + + Returns `{:ok, forms}` when the AST is valid (passes forms through unchanged), + or `{:error, diagnostics}` when errors are found. + + Diagnostics are maps with keys: `:severity`, `:message`, `:line`, `:col`. + """ + + @type diagnostic :: %{ + severity: :error | :warning, + message: String.t(), + line: non_neg_integer(), + col: non_neg_integer() + } + + @doc """ + Analyze and validate a list of CljElixir AST forms. + + Returns `{:ok, forms}` if all validations pass, or `{:error, diagnostics}` + with a list of diagnostic maps describing the errors found. + """ + @spec analyze(list()) :: {:ok, list()} | {:error, [diagnostic()]} + def analyze(forms) when is_list(forms) do + diagnostics = + forms + |> Enum.flat_map(fn form -> validate_form(form, %{tail: true, in_loop: false, in_fn: false}) end) + + case Enum.filter(diagnostics, &(&1.severity == :error)) do + [] -> {:ok, forms} + _errors -> {:error, diagnostics} + end + end + + def analyze(form) do + analyze(List.wrap(form)) + end + + # --------------------------------------------------------------------------- + # Form validation - dispatches on the head of each s-expression + # --------------------------------------------------------------------------- + + # A list form starting with an atom is an s-expression: (special-form ...) + defp validate_form({:list, meta, [{:symbol, _, "defmodule"} | args]}, ctx) do + validate_defmodule(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "defn"} | args]}, ctx) do + validate_defn(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "defn-"} | args]}, ctx) do + validate_defn(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "fn"} | args]}, ctx) do + validate_fn(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "let"} | args]}, ctx) do + validate_let(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "if"} | args]}, ctx) do + validate_if(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "case"} | args]}, ctx) do + validate_case(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "cond"} | args]}, ctx) do + validate_cond(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "loop"} | args]}, ctx) do + validate_loop(args, meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "recur"} | _args]}, ctx) do + validate_recur(meta, ctx) + end + + defp validate_form({:list, meta, [{:symbol, _, "do"} | args]}, ctx) do + validate_do(args, meta, ctx) + end + + defp validate_form({:map, meta, elements}, ctx) do + validate_map_literal(elements, meta, ctx) + end + + # Generic list form: validate children + defp validate_form({:list, _meta, children}, ctx) when is_list(children) do + # In a function call, only the last argument is not necessarily in tail position, + # but for recur analysis, none of the arguments to a call are in tail position + # (since the call itself might be, but its args are not). + non_tail_ctx = %{ctx | tail: false} + + Enum.flat_map(children, fn child -> + validate_form(child, non_tail_ctx) + end) + end + + # Vectors: validate elements + defp validate_form({:vector, _meta, elements}, ctx) when is_list(elements) do + non_tail_ctx = %{ctx | tail: false} + Enum.flat_map(elements, fn el -> validate_form(el, non_tail_ctx) end) + end + + # Sets: validate elements + defp validate_form({:set, _meta, elements}, ctx) when is_list(elements) do + non_tail_ctx = %{ctx | tail: false} + Enum.flat_map(elements, fn el -> validate_form(el, non_tail_ctx) end) + end + + # Atoms, numbers, strings, symbols, keywords — always valid + defp validate_form(_leaf, _ctx), do: [] + + # --------------------------------------------------------------------------- + # Special form validators + # --------------------------------------------------------------------------- + + defp validate_defmodule(args, meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + case args do + [] -> + [ + %{ + severity: :error, + message: "defmodule requires a module name and at least one body expression", + line: line, + col: col + } + ] + + [_name] -> + [ + %{ + severity: :error, + message: "defmodule requires at least one body expression after the module name", + line: line, + col: col + } + ] + + [_name | body] -> + # Body forms are each in tail position within the module (top-level forms) + Enum.flat_map(body, fn form -> + validate_form(form, %{ctx | tail: true, in_loop: false, in_fn: false}) + end) + end + end + + defp validate_defn(args, meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + case args do + [] -> + [ + %{ + severity: :error, + message: "defn requires a function name, parameter vector, and body", + line: line, + col: col + } + ] + + [_name] -> + [ + %{ + severity: :error, + message: "defn requires a parameter vector and body after the function name", + line: line, + col: col + } + ] + + [_name, maybe_doc | rest] -> + # Could be: (defn name [params] body...) + # or: (defn name "docstring" [params] body...) + # or: (defn name ([params1] body1) ([params2] body2)) -- multi-arity + fn_ctx = %{ctx | tail: true, in_fn: true, in_loop: false} + + case maybe_doc do + # Multi-arity: (defn name (clause1) (clause2) ...) + {:list, _, _} -> + clauses = [maybe_doc | rest] + Enum.flat_map(clauses, fn clause -> validate_fn_clause(clause, fn_ctx) end) + + # Docstring form: (defn name "doc" ...) + {:string, _, _} -> + validate_defn_body(rest, fn_ctx, line, col) + + # Single arity with param vector: (defn name [params] body...) + {:vector, _, _} -> + validate_fn_body(rest, fn_ctx) + + _ -> + validate_fn_body(rest, fn_ctx) + end + end + end + + defp validate_defn_body(rest, ctx, line, col) do + case rest do + [] -> + [ + %{ + severity: :error, + message: "defn requires a parameter vector and body after docstring", + line: line, + col: col + } + ] + + [{:vector, _, _} | body] -> + validate_fn_body(body, ctx) + + [{:list, _, _} | _] = clauses -> + # Multi-arity after docstring + Enum.flat_map(clauses, fn clause -> validate_fn_clause(clause, ctx) end) + + _ -> + [] + end + end + + defp validate_fn(args, meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + fn_ctx = %{ctx | tail: true, in_fn: true, in_loop: false} + + case args do + [] -> + [ + %{ + severity: :error, + message: "fn requires a parameter vector and body", + line: line, + col: col + } + ] + + # (fn [params] body...) - single arity + [{:vector, _, _} | body] -> + validate_fn_body(body, fn_ctx) + + # (fn name [params] body...) - named fn + [{:symbol, _, _}, {:vector, _, _} | body] -> + validate_fn_body(body, fn_ctx) + + # (fn (clause1) (clause2) ...) - multi-arity + [{:list, _, _} | _] = clauses -> + Enum.flat_map(clauses, fn clause -> validate_fn_clause(clause, fn_ctx) end) + + # (fn name (clause1) (clause2) ...) - named multi-arity + [{:symbol, _, _} | [{:list, _, _} | _] = clauses] -> + Enum.flat_map(clauses, fn clause -> validate_fn_clause(clause, fn_ctx) end) + + _ -> + [] + end + end + + defp validate_fn_clause({:list, _meta, [{:vector, _, _} | body]}, ctx) do + validate_fn_body(body, ctx) + end + + defp validate_fn_clause(_other, _ctx), do: [] + + defp validate_fn_body([], _ctx), do: [] + + defp validate_fn_body(body, ctx) do + {leading, [last]} = Enum.split(body, -1) + non_tail = %{ctx | tail: false} + + leading_diags = Enum.flat_map(leading, fn form -> validate_form(form, non_tail) end) + last_diags = validate_form(last, ctx) + leading_diags ++ last_diags + end + + defp validate_let(args, meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + case args do + [] -> + [ + %{ + severity: :error, + message: "let requires a binding vector and body", + line: line, + col: col + } + ] + + [{:vector, vmeta, bindings} | body] -> + binding_diags = validate_binding_vector(bindings, vmeta, "let") + + body_diags = + case body do + [] -> + [ + %{ + severity: :warning, + message: "let with no body expression always returns nil", + line: line, + col: col + } + ] + + _ -> + validate_body_forms(body, ctx) + end + + binding_diags ++ body_diags + + _ -> + [ + %{ + severity: :error, + message: "let requires a binding vector as its first argument", + line: line, + col: col + } + ] + end + end + + defp validate_if(args, meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + case length(args) do + n when n < 2 -> + [ + %{ + severity: :error, + message: "if requires a condition and at least a then branch (got #{n} argument(s))", + line: line, + col: col + } + ] + + n when n > 3 -> + [ + %{ + severity: :error, + message: "if accepts at most 3 arguments (condition, then, else), got #{n}", + line: line, + col: col + } + ] + + 2 -> + [condition, then_branch] = args + non_tail = %{ctx | tail: false} + + validate_form(condition, non_tail) ++ + validate_form(then_branch, ctx) + + 3 -> + [condition, then_branch, else_branch] = args + non_tail = %{ctx | tail: false} + + validate_form(condition, non_tail) ++ + validate_form(then_branch, ctx) ++ + validate_form(else_branch, ctx) + end + end + + defp validate_case(args, meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + case args do + [] -> + [ + %{ + severity: :error, + message: "case requires a subject expression and at least one pattern/body pair", + line: line, + col: col + } + ] + + [_subject] -> + [ + %{ + severity: :error, + message: "case requires at least one pattern/body pair after the subject", + line: line, + col: col + } + ] + + [subject | pairs] -> + non_tail = %{ctx | tail: false} + subject_diags = validate_form(subject, non_tail) + + pair_diags = + if rem(length(pairs), 2) != 0 do + [ + %{ + severity: :error, + message: + "case requires an even number of pattern/body forms, got #{length(pairs)}", + line: line, + col: col + } + ] + else + pairs + |> Enum.chunk_every(2) + |> Enum.flat_map(fn + [_pattern, body] -> + validate_form(body, ctx) + + _ -> + [] + end) + end + + subject_diags ++ pair_diags + end + end + + defp validate_cond(args, meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + if rem(length(args), 2) != 0 do + [ + %{ + severity: :error, + message: "cond requires an even number of test/expression pairs, got #{length(args)}", + line: line, + col: col + } + ] + else + non_tail = %{ctx | tail: false} + + args + |> Enum.chunk_every(2) + |> Enum.flat_map(fn + [test, body] -> + validate_form(test, non_tail) ++ validate_form(body, ctx) + + _ -> + [] + end) + end + end + + defp validate_loop(args, meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + case args do + [] -> + [ + %{ + severity: :error, + message: "loop requires a binding vector and body", + line: line, + col: col + } + ] + + [{:vector, vmeta, bindings} | body] -> + binding_diags = validate_binding_vector(bindings, vmeta, "loop") + + body_diags = + case body do + [] -> + [ + %{ + severity: :warning, + message: "loop with no body expression always returns nil", + line: line, + col: col + } + ] + + _ -> + loop_ctx = %{ctx | tail: true, in_loop: true} + validate_body_forms(body, loop_ctx) + end + + binding_diags ++ body_diags + + _ -> + [ + %{ + severity: :error, + message: "loop requires a binding vector as its first argument", + line: line, + col: col + } + ] + end + end + + defp validate_recur(meta, ctx) do + line = meta_line(meta) + col = meta_col(meta) + + cond do + not ctx.tail -> + [ + %{ + severity: :error, + message: "recur must be in tail position", + line: line, + col: col + } + ] + + not (ctx.in_loop or ctx.in_fn) -> + [ + %{ + severity: :error, + message: "recur must be inside a loop or function body", + line: line, + col: col + } + ] + + true -> + [] + end + end + + defp validate_do(args, _meta, ctx) do + validate_body_forms(args, ctx) + end + + defp validate_map_literal(elements, meta, _ctx) do + if rem(length(elements), 2) != 0 do + line = meta_line(meta) + col = meta_col(meta) + + [ + %{ + severity: :error, + message: + "map literal requires an even number of forms (key-value pairs), got #{length(elements)}", + line: line, + col: col + } + ] + else + [] + end + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp validate_binding_vector(bindings, meta, form_name) do + if rem(length(bindings), 2) != 0 do + line = meta_line(meta) + col = meta_col(meta) + + [ + %{ + severity: :error, + message: + "#{form_name} binding vector requires an even number of forms (name/value pairs), got #{length(bindings)}", + line: line, + col: col + } + ] + else + [] + end + end + + defp validate_body_forms([], _ctx), do: [] + + defp validate_body_forms(forms, ctx) do + {leading, [last]} = Enum.split(forms, -1) + non_tail = %{ctx | tail: false} + + leading_diags = Enum.flat_map(leading, fn form -> validate_form(form, non_tail) end) + last_diags = validate_form(last, ctx) + leading_diags ++ last_diags + end + + defp meta_line(meta) when is_map(meta), do: Map.get(meta, :line, 0) + defp meta_line(meta) when is_list(meta), do: Keyword.get(meta, :line, 0) + defp meta_line(_), do: 0 + + defp meta_col(meta) when is_map(meta), do: Map.get(meta, :col, 0) + defp meta_col(meta) when is_list(meta), do: Keyword.get(meta, :col, 0) + defp meta_col(_), do: 0 +end diff --git a/lib/clj_elixir/compiler.ex b/lib/clj_elixir/compiler.ex new file mode 100644 index 0000000..a3cd45a --- /dev/null +++ b/lib/clj_elixir/compiler.ex @@ -0,0 +1,312 @@ +defmodule CljElixir.Compiler do + @moduledoc """ + Orchestrates the CljElixir compilation pipeline. + + Chains: Reader -> Analyzer -> Transformer -> Elixir compilation. + + The pipeline: + + 1. **Reader** (`CljElixir.Reader`) - Parses source text into CljElixir AST + (s-expression forms represented as Elixir terms). + 2. **Analyzer** (`CljElixir.Analyzer`) - Validates the AST, checking special form + arity, map literal structure, recur position, etc. + 3. **Transformer** (`CljElixir.Transformer`) - Converts CljElixir AST into Elixir + AST (`{operation, metadata, arguments}` tuples). + 4. **Elixir Compiler** - `Code.eval_quoted/3` or `Code.compile_quoted/2` handles + macro expansion, protocol consolidation, and BEAM bytecode generation. + """ + + @doc """ + Compile a CljElixir source string to Elixir AST. + + Runs the full pipeline: read -> analyze -> transform. + + Returns `{:ok, elixir_ast}` on success, or `{:error, diagnostics}` on failure. + + ## Options + + * `:file` - the source file path for error reporting (default: `"nofile"`) + """ + @spec compile_string(String.t(), keyword()) :: {:ok, term()} | {:error, list()} + def compile_string(source, opts \\ []) do + file = opts[:file] || "nofile" + + with {:ok, forms} <- read(source, file), + {:ok, forms} <- analyze(forms, opts), + {:ok, elixir_ast} <- transform(forms, opts) do + {:ok, elixir_ast} + end + end + + @doc """ + Compile a `.clje` file to Elixir AST. + + Reads the file from disk and delegates to `compile_string/2` with the + `:file` option set automatically. + + Returns `{:ok, elixir_ast}` on success, or `{:error, diagnostics}` on failure. + Raises `File.Error` if the file cannot be read. + """ + @spec compile_file(Path.t(), keyword()) :: {:ok, term()} | {:error, list()} + def compile_file(path, opts \\ []) do + case File.read(path) do + {:ok, source} -> + compile_string(source, Keyword.put(opts, :file, path)) + + {:error, reason} -> + {:error, + [ + %{ + severity: :error, + message: "could not read file #{path}: #{:file.format_error(reason)}", + file: path, + line: 0, + col: 0 + } + ]} + end + end + + @doc """ + Compile and evaluate a CljElixir source string. + + Compiles the source to Elixir AST and evaluates it via `Code.eval_quoted/3`. + + Returns `{:ok, result, bindings}` on success, or `{:error, diagnostics}` on failure. + + ## Options + + * `:file` - the source file path for error reporting (default: `"nofile"`) + * `:bindings` - variable bindings for evaluation (default: `[]`) + * `:env` - the macro environment for evaluation + """ + @spec eval_string(String.t(), keyword()) :: {:ok, term(), keyword()} | {:error, list()} + def eval_string(source, opts \\ []) do + with {:ok, ast} <- compile_string(source, opts) do + try do + bindings = opts[:bindings] || [] + env_opts = build_eval_opts(opts) + {result, new_bindings} = Code.eval_quoted(ast, bindings, env_opts) + {:ok, result, new_bindings} + rescue + e -> + file = opts[:file] || "nofile" + + {:error, + [ + %{ + severity: :error, + message: format_eval_error(e), + file: file, + line: extract_line(e), + col: 0 + } + ]} + end + end + end + + @doc """ + Compile and evaluate a `.clje` file. + + Reads the file from disk and delegates to `eval_string/2`. + + Returns `{:ok, result, bindings}` on success, or `{:error, diagnostics}` on failure. + Raises `File.Error` if the file cannot be read. + """ + @spec eval_file(Path.t(), keyword()) :: {:ok, term(), keyword()} | {:error, list()} + def eval_file(path, opts \\ []) do + case File.read(path) do + {:ok, source} -> + eval_string(source, Keyword.put(opts, :file, path)) + + {:error, reason} -> + {:error, + [ + %{ + severity: :error, + message: "could not read file #{path}: #{:file.format_error(reason)}", + file: path, + line: 0, + col: 0 + } + ]} + end + end + + @doc """ + Compile a CljElixir source string to BEAM modules and write .beam files. + + Compiles the source to Elixir AST and then uses `Code.compile_quoted/2` to + produce BEAM bytecode modules. + + Returns `{:ok, [{module, binary}]}` on success, or `{:error, diagnostics}` on failure. + + ## Options + + * `:file` - the source file path for error reporting (default: `"nofile"`) + """ + @spec compile_to_beam(String.t(), keyword()) :: + {:ok, [{module(), binary()}]} | {:error, list()} + def compile_to_beam(source, opts \\ []) do + with {:ok, ast} <- compile_string(source, opts) do + try do + file = opts[:file] || "nofile" + modules = Code.compile_quoted(ast, file) + {:ok, modules} + rescue + e -> + file = opts[:file] || "nofile" + + {:error, + [ + %{ + severity: :error, + message: "BEAM compilation failed: #{format_eval_error(e)}", + file: file, + line: extract_line(e), + col: 0 + } + ]} + end + end + end + + @doc """ + Compile a `.clje` file to BEAM modules and write .beam files to the given + output directory. + + Returns `{:ok, [{module, binary}]}` on success, or `{:error, diagnostics}` on failure. + + ## Options + + * `:output_dir` - directory to write .beam files (default: does not write) + """ + @spec compile_file_to_beam(Path.t(), keyword()) :: + {:ok, [{module(), binary()}]} | {:error, list()} + def compile_file_to_beam(path, opts \\ []) do + case File.read(path) do + {:ok, source} -> + opts = Keyword.put(opts, :file, path) + + with {:ok, modules} <- compile_to_beam(source, opts) do + case opts[:output_dir] do + nil -> + {:ok, modules} + + output_dir -> + write_beam_files(modules, output_dir) + end + end + + {:error, reason} -> + {:error, + [ + %{ + severity: :error, + message: "could not read file #{path}: #{:file.format_error(reason)}", + file: path, + line: 0, + col: 0 + } + ]} + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp read(source, file) do + case CljElixir.Reader.read_string(source) do + {:ok, forms} -> + {:ok, forms} + + {:error, reason} when is_binary(reason) -> + {:error, + [%{severity: :error, message: "read error: #{reason}", file: file, line: 0, col: 0}]} + + {:error, reason} -> + {:error, + [ + %{ + severity: :error, + message: "read error: #{inspect(reason)}", + file: file, + line: 0, + col: 0 + } + ]} + end + end + + defp analyze(forms, _opts) do + case CljElixir.Analyzer.analyze(forms) do + {:ok, analyzed_forms} -> + {:ok, analyzed_forms} + + {:error, diagnostics} when is_list(diagnostics) -> + {:error, diagnostics} + + {:error, reason} -> + {:error, + [%{severity: :error, message: "analysis error: #{inspect(reason)}", line: 0, col: 0}]} + end + end + + defp transform(forms, opts) do + try do + ctx = + if opts[:vector_as_list] do + %CljElixir.Transformer.Context{vector_as_list: true} + else + %CljElixir.Transformer.Context{} + end + + elixir_ast = CljElixir.Transformer.transform(forms, ctx) + {:ok, elixir_ast} + rescue + e -> + {:error, + [ + %{ + severity: :error, + message: "transform error: #{format_eval_error(e)}", + line: 0, + col: 0 + } + ]} + end + end + + defp build_eval_opts(opts) do + eval_opts = [file: opts[:file] || "nofile"] + + case opts[:env] do + nil -> eval_opts + env -> Keyword.put(eval_opts, :env, env) + end + end + + defp format_eval_error(%{__struct__: struct} = e) when is_atom(struct) do + Exception.message(e) + rescue + _ -> inspect(e) + end + + defp format_eval_error(e), do: inspect(e) + + defp extract_line(%{line: line}) when is_integer(line), do: line + defp extract_line(_), do: 0 + + defp write_beam_files(modules, output_dir) do + File.mkdir_p!(output_dir) + + Enum.each(modules, fn {module, binary} -> + beam_path = Path.join(output_dir, "#{module}.beam") + File.write!(beam_path, binary) + end) + + {:ok, modules} + end +end diff --git a/lib/clj_elixir/interop.ex b/lib/clj_elixir/interop.ex new file mode 100644 index 0000000..34cad6d --- /dev/null +++ b/lib/clj_elixir/interop.ex @@ -0,0 +1,112 @@ +defmodule CljElixir.Equality do + @moduledoc """ + Cross-type equality for CljElixir. + + Handles the case where `(= [1 2 3] '(1 2 3))` should return true — + PersistentVector and list are both sequential types with the same elements. + """ + + def equiv(a, b) when a === b, do: true + + def equiv(%CljElixir.PersistentVector{} = a, %CljElixir.PersistentVector{} = b) do + a.cnt == b.cnt and CljElixir.PersistentVector.to_list(a) == CljElixir.PersistentVector.to_list(b) + end + + def equiv(%CljElixir.PersistentVector{} = a, b) when is_list(b) do + CljElixir.PersistentVector.to_list(a) == b + end + + def equiv(a, %CljElixir.PersistentVector{} = b) when is_list(a) do + a == CljElixir.PersistentVector.to_list(b) + end + + def equiv(%CljElixir.SubVector{} = a, b) do + CljElixir.SubVector.sv_to_list(a) |> equiv_list(b) + end + + def equiv(a, %CljElixir.SubVector{} = b) do + equiv_list(CljElixir.SubVector.sv_to_list(b), a) + end + + def equiv(a, b), do: a == b + + defp equiv_list(list, other) when is_list(other), do: list == other + + defp equiv_list(list, %CljElixir.PersistentVector{} = pv) do + list == CljElixir.PersistentVector.to_list(pv) + end + + defp equiv_list(_, _), do: false +end + +defimpl Enumerable, for: CljElixir.PersistentVector do + def count(pv), do: {:ok, pv.cnt} + + def member?(_pv, _value), do: {:error, __MODULE__} + + def reduce(_pv, {:halt, acc}, _fun), do: {:halted, acc} + def reduce(pv, {:suspend, acc}, fun), do: {:suspended, acc, &reduce(pv, &1, fun)} + + def reduce(pv, {:cont, acc}, fun) do + list = CljElixir.PersistentVector.to_list(pv) + Enumerable.List.reduce(list, {:cont, acc}, fun) + end + + def slice(pv) do + size = pv.cnt + {:ok, size, &slice_fun(pv, &1, &2, &3)} + end + + defp slice_fun(pv, start, length, step) do + start..(start + (length - 1) * step)//step + |> Enum.map(fn i -> CljElixir.PersistentVector.pv_nth(pv, i) end) + end +end + +defimpl Collectable, for: CljElixir.PersistentVector do + def into(pv) do + collector_fun = fn + acc, {:cont, elem} -> CljElixir.PersistentVector.pv_conj(acc, elem) + acc, :done -> acc + _acc, :halt -> :ok + end + + {pv, collector_fun} + end +end + +defimpl Enumerable, for: Tuple do + def count(t), do: {:ok, tuple_size(t)} + + def member?(_t, _value), do: {:error, __MODULE__} + + def reduce(_t, {:halt, acc}, _fun), do: {:halted, acc} + def reduce(t, {:suspend, acc}, fun), do: {:suspended, acc, &reduce(t, &1, fun)} + + def reduce(t, {:cont, acc}, fun) do + list = Tuple.to_list(t) + Enumerable.List.reduce(list, {:cont, acc}, fun) + end + + def slice(t) do + size = tuple_size(t) + {:ok, size, &slice_fun(t, &1, &2, &3)} + end + + defp slice_fun(t, start, length, step) do + start..(start + (length - 1) * step)//step + |> Enum.map(fn i -> elem(t, i) end) + end +end + +defimpl Collectable, for: Tuple do + def into(t) do + collector_fun = fn + acc, {:cont, elem} -> :erlang.append_element(acc, elem) + acc, :done -> acc + _acc, :halt -> :ok + end + + {t, collector_fun} + end +end diff --git a/lib/clj_elixir/malli.ex b/lib/clj_elixir/malli.ex new file mode 100644 index 0000000..18a4770 --- /dev/null +++ b/lib/clj_elixir/malli.ex @@ -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 diff --git a/lib/clj_elixir/reader.ex b/lib/clj_elixir/reader.ex new file mode 100644 index 0000000..03d8650 --- /dev/null +++ b/lib/clj_elixir/reader.ex @@ -0,0 +1,647 @@ +defmodule CljElixir.Reader do + @moduledoc """ + Reader for CljElixir: tokenizes source text and parses it into CljElixir AST. + + The reader has two phases: + 1. Tokenizer — converts source text into a flat list of tokens + 2. Parser — recursive descent over the token list, producing CljElixir AST nodes + + ## AST representation + + Literals are themselves: integers, floats, strings, booleans, nil, atoms (keywords). + + Compound forms use tagged tuples: + {:symbol, meta, name} + {:list, meta, [elements]} + {:vector, meta, [elements]} + {:map, meta, [k1, v1, k2, v2, ...]} + {:set, meta, [elements]} + {:tuple, meta, [elements]} + {:regex, meta, pattern} + {:quote, meta, form} + {:with_meta, meta, {metadata, target}} + {:anon_fn, meta, body} + {:quasiquote, meta, form} + {:unquote, meta, form} + {:splice_unquote, meta, form} + {:deref, meta, form} + """ + + alias CljElixir.Reader.Token + + # ── Public API ────────────────────────────────────────────────────── + + @doc """ + Read a string of CljElixir source into a list of AST forms. + + Returns `{:ok, [form]}` on success, `{:error, message}` on failure. + """ + @spec read_string(String.t()) :: {:ok, list()} | {:error, String.t()} + def read_string(source) when is_binary(source) do + case tokenize(source) do + {:ok, tokens} -> + parse_all(tokens, []) + + {:error, _} = err -> + err + end + end + + # ════════════════════════════════════════════════════════════════════ + # TOKENIZER + # ════════════════════════════════════════════════════════════════════ + + @doc false + def tokenize(source) do + chars = String.to_charlist(source) + tokenize_loop(chars, 1, 1, []) + end + + # ---------- end of input ---------- + defp tokenize_loop([], _line, _col, acc), do: {:ok, Enum.reverse(acc)} + + # ---------- newline ---------- + defp tokenize_loop([?\n | rest], line, _col, acc), + do: tokenize_loop(rest, line + 1, 1, acc) + + defp tokenize_loop([?\r, ?\n | rest], line, _col, acc), + do: tokenize_loop(rest, line + 1, 1, acc) + + defp tokenize_loop([?\r | rest], line, _col, acc), + do: tokenize_loop(rest, line + 1, 1, acc) + + # ---------- whitespace / commas ---------- + defp tokenize_loop([c | rest], line, col, acc) when c in [?\s, ?\t, ?,], + do: tokenize_loop(rest, line, col + 1, acc) + + # ---------- comments ---------- + defp tokenize_loop([?; | rest], line, _col, acc) do + rest = skip_comment(rest) + # skip_comment stops at (but does not consume) the newline or EOF. + # Let the main loop's newline handler increment line/col. + tokenize_loop(rest, line, 1, acc) + end + + # ---------- strings ---------- + defp tokenize_loop([?" | rest], line, col, acc) do + case read_string_literal(rest, line, col + 1, []) do + {:ok, value, rest2, end_line, end_col} -> + token = %Token{type: :string, value: value, line: line, col: col} + tokenize_loop(rest2, end_line, end_col, [token | acc]) + + {:error, msg} -> + {:error, msg} + end + end + + # ---------- dispatch sequences: #{ #el[ #( #" ---------- + defp tokenize_loop([?#, ?e, ?l, ?[ | rest], line, col, acc) do + token = %Token{type: :hash_el_lbracket, value: "#el[", line: line, col: col} + tokenize_loop(rest, line, col + 4, [token | acc]) + end + + defp tokenize_loop([?#, ?{ | rest], line, col, acc) do + token = %Token{type: :hash_lbrace, value: "\#{", line: line, col: col} + tokenize_loop(rest, line, col + 2, [token | acc]) + end + + defp tokenize_loop([?#, ?( | rest], line, col, acc) do + token = %Token{type: :hash_lparen, value: "#(", line: line, col: col} + tokenize_loop(rest, line, col + 2, [token | acc]) + end + + defp tokenize_loop([?#, ?" | rest], line, col, acc) do + case read_string_literal(rest, line, col + 2, []) do + {:ok, value, rest2, end_line, end_col} -> + token = %Token{type: :hash_string, value: value, line: line, col: col} + tokenize_loop(rest2, end_line, end_col, [token | acc]) + + {:error, msg} -> + {:error, msg} + end + end + + # ---------- splice-unquote ~@ (must come before unquote ~) ---------- + defp tokenize_loop([?~, ?@ | rest], line, col, acc) do + token = %Token{type: :splice_unquote, value: "~@", line: line, col: col} + tokenize_loop(rest, line, col + 2, [token | acc]) + end + + # ---------- unquote ~ ---------- + defp tokenize_loop([?~ | rest], line, col, acc) do + token = %Token{type: :unquote, value: "~", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + # ---------- delimiters ---------- + defp tokenize_loop([?( | rest], line, col, acc) do + token = %Token{type: :lparen, value: "(", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + defp tokenize_loop([?) | rest], line, col, acc) do + token = %Token{type: :rparen, value: ")", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + defp tokenize_loop([?[ | rest], line, col, acc) do + token = %Token{type: :lbracket, value: "[", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + defp tokenize_loop([?] | rest], line, col, acc) do + token = %Token{type: :rbracket, value: "]", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + defp tokenize_loop([?{ | rest], line, col, acc) do + token = %Token{type: :lbrace, value: "{", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + defp tokenize_loop([?} | rest], line, col, acc) do + token = %Token{type: :rbrace, value: "}", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + # ---------- quote ' ---------- + defp tokenize_loop([?' | rest], line, col, acc) do + token = %Token{type: :quote, value: "'", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + # ---------- quasiquote ` ---------- + defp tokenize_loop([?` | rest], line, col, acc) do + token = %Token{type: :quasiquote, value: "`", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + # ---------- metadata ^ ---------- + defp tokenize_loop([?^ | rest], line, col, acc) do + token = %Token{type: :meta, value: "^", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + # ---------- deref @ ---------- + defp tokenize_loop([?@ | rest], line, col, acc) do + token = %Token{type: :deref, value: "@", line: line, col: col} + tokenize_loop(rest, line, col + 1, [token | acc]) + end + + # ---------- keywords ---------- + defp tokenize_loop([?: | rest], line, col, acc) do + case read_keyword(rest, line, col) do + {:ok, kw_value, rest2, end_col} -> + token = %Token{type: :keyword, value: kw_value, line: line, col: col} + tokenize_loop(rest2, line, end_col, [token | acc]) + + {:error, msg} -> + {:error, msg} + end + end + + # ---------- negative numbers: - ---------- + # Since whitespace is always consumed before reaching tokenize_loop, + # a standalone `-` followed by a digit is always a negative number literal. + # The `-` inside symbol names (like `my-func`) is consumed by the symbol reader + # and never reaches this clause as a standalone character. + defp tokenize_loop([?- | rest], line, col, acc) do + if starts_with_digit?(rest) do + {:ok, token, rest2, end_col} = read_number(rest, line, col + 1, [?-]) + token = %{token | line: line, col: col} + tokenize_loop(rest2, line, end_col, [token | acc]) + else + # It's a symbol starting with - + case read_symbol([?- | rest], line, col) do + {:ok, token, rest2, end_col} -> + tokenize_loop(rest2, line, end_col, [token | acc]) + end + end + end + + # ---------- numbers ---------- + defp tokenize_loop([c | _] = chars, line, col, acc) when c in ?0..?9 do + {:ok, token, rest2, end_col} = read_number(chars, line, col, []) + tokenize_loop(rest2, line, end_col, [token | acc]) + end + + # ---------- symbols (and true/false/nil) ---------- + defp tokenize_loop([c | _] = chars, line, col, acc) + when c in ?a..?z or c in ?A..?Z or + c == ?_ or c == ?* or c == ?! or c == ?? or + c == ?< or c == ?> or c == ?= or c == ?+ or + c == ?. or c == ?& or c == ?% do + case read_symbol(chars, line, col) do + {:ok, token, rest, end_col} -> + tokenize_loop(rest, line, end_col, [token | acc]) + end + end + + # ---------- catch-all: unexpected character ---------- + defp tokenize_loop([c | _], line, col, _acc) do + {:error, "Unexpected character '#{<>}' at line #{line}, col #{col}"} + end + + # ── Tokenizer helpers ─────────────────────────────────────────────── + + # Characters that can continue a symbol (after the start) + defp symbol_continue_char?(c) when c in ?a..?z, do: true + defp symbol_continue_char?(c) when c in ?A..?Z, do: true + defp symbol_continue_char?(c) when c in ?0..?9, do: true + defp symbol_continue_char?(c) when c in [?_, ?*, ?!, ??, ?<, ?>, ?=, ?+, ?-, ?/, ?., ?%, ?&, ?#], do: true + defp symbol_continue_char?(_), do: false + + defp starts_with_digit?([c | _]) when c in ?0..?9, do: true + defp starts_with_digit?(_), do: false + + defp skip_comment([?\n | _] = rest), do: rest + defp skip_comment([?\r | _] = rest), do: rest + defp skip_comment([]), do: [] + defp skip_comment([_ | rest]), do: skip_comment(rest) + + # ── String literal reader ────────────────────────────────────────── + + defp read_string_literal([], line, _col, _acc), + do: {:error, "Unterminated string starting at line #{line}"} + + defp read_string_literal([?" | rest], line, col, acc), + do: {:ok, IO.chardata_to_string(Enum.reverse(acc)), rest, line, col + 1} + + defp read_string_literal([?\\, ?" | rest], line, col, acc), + do: read_string_literal(rest, line, col + 2, [?" | acc]) + + defp read_string_literal([?\\, ?\\ | rest], line, col, acc), + do: read_string_literal(rest, line, col + 2, [?\\ | acc]) + + defp read_string_literal([?\\, ?n | rest], line, col, acc), + do: read_string_literal(rest, line, col + 2, [?\n | acc]) + + defp read_string_literal([?\\, ?t | rest], line, col, acc), + do: read_string_literal(rest, line, col + 2, [?\t | acc]) + + defp read_string_literal([?\\, ?r | rest], line, col, acc), + do: read_string_literal(rest, line, col + 2, [?\r | acc]) + + defp read_string_literal([?\n | rest], line, _col, acc), + do: read_string_literal(rest, line + 1, 1, [?\n | acc]) + + defp read_string_literal([c | rest], line, col, acc), + do: read_string_literal(rest, line, col + 1, [c | acc]) + + # ── Keyword reader ───────────────────────────────────────────────── + + # Quoted keyword: :"some-name" + defp read_keyword([?" | rest], line, col) do + case read_string_literal(rest, line, col + 2, []) do + {:ok, value, rest2, _end_line, end_col} -> + {:ok, String.to_atom(value), rest2, end_col} + + {:error, msg} -> + {:error, msg} + end + end + + # Regular keyword: :name, :my-key, :ok + defp read_keyword(chars, _line, col) do + {name_chars, rest} = take_keyword_chars(chars, []) + + case name_chars do + [] -> + {:error, "Expected keyword name after ':'"} + + _ -> + name = IO.chardata_to_string(Enum.reverse(name_chars)) + atom_val = String.to_atom(name) + {:ok, atom_val, rest, col + 1 + length(name_chars)} + end + end + + defp take_keyword_chars([c | rest], acc) when c in ?a..?z or c in ?A..?Z or c in ?0..?9 or c in [?_, ?-, ?!, ??, ?., ?/, ?*, ?+, ?>, ?<, ?=, ?&, ?#], + do: take_keyword_chars(rest, [c | acc]) + + defp take_keyword_chars(rest, acc), do: {acc, rest} + + # ── Number reader ────────────────────────────────────────────────── + + defp read_number(chars, line, col, prefix) do + {digit_chars, rest} = take_digits(chars, prefix) + + case rest do + [?. | after_dot] -> + case after_dot do + [d | _] when d in ?0..?9 -> + {frac_chars, rest2} = take_digits(after_dot, [?. | digit_chars]) + str = IO.chardata_to_string(Enum.reverse(frac_chars)) + {float_val, ""} = Float.parse(str) + end_col = col + String.length(str) - length(prefix) + token = %Token{type: :float, value: float_val, line: line, col: col} + {:ok, token, rest2, end_col} + + _ -> + # dot not followed by digit — just an integer, leave dot for next token + str = IO.chardata_to_string(Enum.reverse(digit_chars)) + {int_val, ""} = Integer.parse(str) + end_col = col + String.length(str) - length(prefix) + token = %Token{type: :integer, value: int_val, line: line, col: col} + {:ok, token, rest, end_col} + end + + _ -> + str = IO.chardata_to_string(Enum.reverse(digit_chars)) + {int_val, ""} = Integer.parse(str) + end_col = col + String.length(str) - length(prefix) + token = %Token{type: :integer, value: int_val, line: line, col: col} + {:ok, token, rest, end_col} + end + end + + defp take_digits([c | rest], acc) when c in ?0..?9, + do: take_digits(rest, [c | acc]) + + defp take_digits(rest, acc), do: {acc, rest} + + # ── Symbol reader ────────────────────────────────────────────────── + + defp read_symbol(chars, line, col) do + {sym_chars, rest} = take_symbol_chars(chars, []) + name = IO.chardata_to_string(Enum.reverse(sym_chars)) + end_col = col + String.length(name) + + token = + case name do + "true" -> %Token{type: :boolean, value: true, line: line, col: col} + "false" -> %Token{type: :boolean, value: false, line: line, col: col} + "nil" -> %Token{type: :nil, value: nil, line: line, col: col} + _ -> %Token{type: :symbol, value: name, line: line, col: col} + end + + {:ok, token, rest, end_col} + end + + defp take_symbol_chars([c | rest], acc) do + if (acc == [] && symbol_start_char?(c)) || (acc != [] && symbol_continue_char?(c)) do + take_symbol_chars(rest, [c | acc]) + else + {acc, [c | rest]} + end + end + + defp take_symbol_chars([], acc), do: {acc, []} + + defp symbol_start_char?(c) when c in ?a..?z, do: true + defp symbol_start_char?(c) when c in ?A..?Z, do: true + defp symbol_start_char?(c) when c in [?_, ?*, ?!, ??, ?<, ?>, ?=, ?+, ?-, ?., ?&, ?%], do: true + defp symbol_start_char?(_), do: false + + # ════════════════════════════════════════════════════════════════════ + # PARSER — Recursive Descent + # ════════════════════════════════════════════════════════════════════ + + # Parse all top-level forms until tokens are exhausted + defp parse_all([], acc), do: {:ok, Enum.reverse(acc)} + + defp parse_all(tokens, acc) do + case parse_form(tokens) do + {:ok, form, rest} -> + parse_all(rest, [form | acc]) + + {:error, _} = err -> + err + end + end + + # ── Parse a single form ──────────────────────────────────────────── + + # Literals + defp parse_form([%Token{type: :integer, value: v} | rest]), + do: {:ok, v, rest} + + defp parse_form([%Token{type: :float, value: v} | rest]), + do: {:ok, v, rest} + + defp parse_form([%Token{type: :string, value: v} | rest]), + do: {:ok, v, rest} + + defp parse_form([%Token{type: :keyword, value: v} | rest]), + do: {:ok, v, rest} + + defp parse_form([%Token{type: :boolean, value: v} | rest]), + do: {:ok, v, rest} + + defp parse_form([%Token{type: :nil} | rest]), + do: {:ok, nil, rest} + + # Symbol + defp parse_form([%Token{type: :symbol, value: name, line: l, col: c} | rest]), + do: {:ok, {:symbol, %{line: l, col: c}, name}, rest} + + # List ( ... ) + defp parse_form([%Token{type: :lparen, line: l, col: c} | rest]) do + case parse_until(rest, :rparen) do + {:ok, elements, rest2} -> + {:ok, {:list, %{line: l, col: c}, elements}, rest2} + + {:error, _} = err -> + err + end + end + + # Vector [ ... ] + defp parse_form([%Token{type: :lbracket, line: l, col: c} | rest]) do + case parse_until(rest, :rbracket) do + {:ok, elements, rest2} -> + {:ok, {:vector, %{line: l, col: c}, elements}, rest2} + + {:error, _} = err -> + err + end + end + + # Map { ... } + defp parse_form([%Token{type: :lbrace, line: l, col: c} | rest]) do + case parse_until(rest, :rbrace) do + {:ok, elements, rest2} -> + {:ok, {:map, %{line: l, col: c}, elements}, rest2} + + {:error, _} = err -> + err + end + end + + # Set #{ ... } + defp parse_form([%Token{type: :hash_lbrace, line: l, col: c} | rest]) do + case parse_until(rest, :rbrace) do + {:ok, elements, rest2} -> + {:ok, {:set, %{line: l, col: c}, elements}, rest2} + + {:error, _} = err -> + err + end + end + + # BEAM tuple #el[ ... ] + defp parse_form([%Token{type: :hash_el_lbracket, line: l, col: c} | rest]) do + case parse_until(rest, :rbracket) do + {:ok, elements, rest2} -> + {:ok, {:tuple, %{line: l, col: c}, elements}, rest2} + + {:error, _} = err -> + err + end + end + + # Anonymous function #( ... ) + defp parse_form([%Token{type: :hash_lparen, line: l, col: c} | rest]) do + case parse_until(rest, :rparen) do + {:ok, elements, rest2} -> + body = {:list, %{line: l, col: c}, elements} + {:ok, {:anon_fn, %{line: l, col: c}, body}, rest2} + + {:error, _} = err -> + err + end + end + + # Regex #"..." + defp parse_form([%Token{type: :hash_string, value: pattern, line: l, col: c} | rest]), + do: {:ok, {:regex, %{line: l, col: c}, pattern}, rest} + + # Quote ' + defp parse_form([%Token{type: :quote, line: l, col: c} | rest]) do + case parse_form(rest) do + {:ok, form, rest2} -> + {:ok, {:quote, %{line: l, col: c}, form}, rest2} + + {:error, _} = err -> + err + end + end + + # Quasiquote ` + defp parse_form([%Token{type: :quasiquote, line: l, col: c} | rest]) do + case parse_form(rest) do + {:ok, form, rest2} -> + {:ok, {:quasiquote, %{line: l, col: c}, form}, rest2} + + {:error, _} = err -> + err + end + end + + # Unquote ~ + defp parse_form([%Token{type: :unquote, line: l, col: c} | rest]) do + case parse_form(rest) do + {:ok, form, rest2} -> + {:ok, {:unquote, %{line: l, col: c}, form}, rest2} + + {:error, _} = err -> + err + end + end + + # Splice-unquote ~@ + defp parse_form([%Token{type: :splice_unquote, line: l, col: c} | rest]) do + case parse_form(rest) do + {:ok, form, rest2} -> + {:ok, {:splice_unquote, %{line: l, col: c}, form}, rest2} + + {:error, _} = err -> + err + end + end + + # Deref @ + defp parse_form([%Token{type: :deref, line: l, col: c} | rest]) do + case parse_form(rest) do + {:ok, form, rest2} -> + {:ok, {:deref, %{line: l, col: c}, form}, rest2} + + {:error, _} = err -> + err + end + end + + # Metadata ^ + defp parse_form([%Token{type: :meta, line: l, col: c} | rest]) do + case parse_meta_value(rest, l, c) do + {:ok, meta_form, rest2} -> + case parse_form(rest2) do + {:ok, target, rest3} -> + {:ok, {:with_meta, %{line: l, col: c}, {meta_form, target}}, rest3} + + {:error, _} = err -> + err + end + + {:error, _} = err -> + err + end + end + + # Unexpected token + defp parse_form([%Token{type: type, line: l, col: c} | _]), + do: {:error, "Unexpected token #{type} at line #{l}, col #{c}"} + + defp parse_form([]), + do: {:error, "Unexpected end of input"} + + # ── Parse helpers ────────────────────────────────────────────────── + + # Parse elements until a closing delimiter token type is found + defp parse_until(tokens, closer) do + parse_until_loop(tokens, closer, []) + end + + defp parse_until_loop([], closer, _acc) do + name = delimiter_name(closer) + {:error, "Unexpected end of input, expected '#{name}'"} + end + + defp parse_until_loop([%Token{type: type} | rest], closer, acc) when type == closer do + {:ok, Enum.reverse(acc), rest} + end + + defp parse_until_loop(tokens, closer, acc) do + case parse_form(tokens) do + {:ok, form, rest} -> + parse_until_loop(rest, closer, [form | acc]) + + {:error, _} = err -> + err + end + end + + # Parse the value after ^ (metadata) + # ^{...} — map metadata + defp parse_meta_value([%Token{type: :lbrace, line: l, col: c} | rest], _ml, _mc) do + case parse_until(rest, :rbrace) do + {:ok, elements, rest2} -> + {:ok, {:map, %{line: l, col: c}, elements}, rest2} + + {:error, _} = err -> + err + end + end + + # ^:keyword — sugar for ^{:keyword true} + defp parse_meta_value([%Token{type: :keyword, value: kw, line: l, col: c} | rest], _ml, _mc) do + meta_map = {:map, %{line: l, col: c}, [kw, true]} + {:ok, meta_map, rest} + end + + # ^symbol — sugar for ^{:tag symbol} + defp parse_meta_value([%Token{type: :symbol} | _] = tokens, _ml, _mc) do + case parse_form(tokens) do + {:ok, form, rest} -> {:ok, form, rest} + {:error, _} = err -> err + end + end + + defp parse_meta_value(_tokens, ml, mc) do + {:error, "Expected metadata value (map, keyword, or symbol) at line #{ml}, col #{mc}"} + end + + defp delimiter_name(:rparen), do: ")" + defp delimiter_name(:rbracket), do: "]" + defp delimiter_name(:rbrace), do: "}" +end diff --git a/lib/clj_elixir/reader/token.ex b/lib/clj_elixir/reader/token.ex new file mode 100644 index 0000000..1a1fb1c --- /dev/null +++ b/lib/clj_elixir/reader/token.ex @@ -0,0 +1,14 @@ +defmodule CljElixir.Reader.Token do + @moduledoc """ + A token produced by the CljElixir tokenizer. + + Types: + :integer, :float, :string, :keyword, :symbol, :boolean, :nil, + :lparen, :rparen, :lbracket, :rbracket, :lbrace, :rbrace, + :hash_lbrace, :hash_el_lbracket, :hash_lparen, :hash_string, + :quote, :quasiquote, :unquote, :splice_unquote, + :meta, :deref + """ + + defstruct [:type, :value, :line, :col] +end diff --git a/lib/clj_elixir/transformer.ex b/lib/clj_elixir/transformer.ex new file mode 100644 index 0000000..1bc7146 --- /dev/null +++ b/lib/clj_elixir/transformer.ex @@ -0,0 +1,3277 @@ +defmodule CljElixir.Transformer do + @moduledoc """ + Transforms CljElixir AST (from the reader) into Elixir AST (quoted expressions). + + CljElixir AST nodes: + - Literals: integers, floats, strings, atoms, booleans, nil + - {:symbol, meta, "name"} + - {:list, meta, [elements]} + - {:vector, meta, [elements]} + - {:map, meta, [k1, v1, k2, v2, ...]} + - {:set, meta, [elements]} + - {:tuple, meta, [elements]} + - {:regex, meta, "pattern"} + - {:quote, meta, inner} + - {:with_meta, meta, {metadata, target}} + - {:anon_fn, meta, body} + - {:quasiquote, meta, form} + - {:unquote, meta, form} + - {:splice_unquote, meta, form} + - {:deref, meta, form} + + Elixir AST: {atom, keyword_list_meta, args_list} for calls, literals for literals. + """ + + # --------------------------------------------------------------------------- + # Context + # --------------------------------------------------------------------------- + + defmodule Context do + @moduledoc false + defstruct module_name: nil, + function_name: nil, + function_arity: nil, + loop_var: nil, + loop_arity: nil, + in_pattern: false, + records: %{}, + gensym_counter: 0, + vector_as_list: false, + macros: %{}, + in_macro: false, + schemas: %{} + end + + # --------------------------------------------------------------------------- + # Public API + # --------------------------------------------------------------------------- + + @doc """ + Transform a list of CljElixir AST forms into a single Elixir AST node. + Returns a __block__ wrapping all transformed forms (or a single form if only one). + """ + def transform(forms, ctx \\ %Context{}) + + def transform(forms, ctx) when is_list(forms) do + {elixir_forms, _ctx} = + Enum.map_reduce(forms, ctx, fn form, acc -> + {ast, new_ctx} = transform_form(form, acc) + {ast, new_ctx} + end) + + # Filter out nil (from defmacro which produces no runtime code) + elixir_forms = Enum.filter(elixir_forms, &(&1 != nil)) + + case elixir_forms do + [] -> nil + [single] -> single + multiple -> {:__block__, [], multiple} + end + end + + def transform(form, ctx) do + {ast, _ctx} = transform_form(form, ctx) + ast + end + + # --------------------------------------------------------------------------- + # Main dispatch + # --------------------------------------------------------------------------- + + @doc false + def transform_form(form, ctx) do + do_transform(form, ctx) + end + + # --- Literals pass through --- + defp do_transform(n, ctx) when is_integer(n), do: {n, ctx} + defp do_transform(n, ctx) when is_float(n), do: {n, ctx} + defp do_transform(s, ctx) when is_binary(s), do: {s, ctx} + defp do_transform(true, ctx), do: {true, ctx} + defp do_transform(false, ctx), do: {false, ctx} + defp do_transform(nil, ctx), do: {nil, ctx} + defp do_transform(a, ctx) when is_atom(a), do: {a, ctx} + + # --- Symbols --- + defp do_transform({:symbol, meta, name}, ctx) when is_binary(name) do + transform_symbol(name, meta, ctx) + end + + # --- Lists (function calls and special forms) --- + defp do_transform({:list, meta, elements}, ctx) do + transform_list(elements, meta, ctx) + end + + # --- Vectors --- + defp do_transform({:vector, meta, elements}, ctx) do + transform_vector(elements, meta, ctx) + end + + # --- Maps --- + defp do_transform({:map, _meta, pairs}, ctx) do + transform_map(pairs, ctx) + end + + # --- Sets --- + defp do_transform({:set, _meta, elements}, ctx) do + transform_set(elements, ctx) + end + + # --- Tuples --- + defp do_transform({:tuple, _meta, elements}, ctx) do + transformed = Enum.map(elements, fn e -> transform(e, ctx) end) + {{:{}, [], transformed}, ctx} + end + + # --- Regex --- + defp do_transform({:regex, _meta, pattern}, ctx) do + ast = + {:sigil_r, [delimiter: "/"], + [{:<<>>, [], [pattern]}, []]} + + {ast, ctx} + end + + # --- Quote --- + defp do_transform({:quote, _meta, inner}, ctx) do + {quote_form(inner), ctx} + end + + # --- Metadata --- + defp do_transform({:with_meta, _meta, {_metadata, target}}, ctx) do + # For now, ignore metadata and transform the target + do_transform(target, ctx) + end + + # --- Anon fn shorthand #(...) --- + defp do_transform({:anon_fn, _meta, body}, ctx) do + transform_anon_fn(body, ctx) + end + + # --- Quasiquote --- + defp do_transform({:quasiquote, _meta, form}, ctx) do + if ctx.in_macro do + # In macro body: produce code that constructs CljElixir AST + {gensyms, gensym_map} = collect_gensyms(form) + gensym_bindings = generate_gensym_bindings(gensyms) + qq_ast = transform_macro_quasiquote(form, ctx, gensym_map) + + ast = + if gensym_bindings == [] do + qq_ast + else + {:__block__, [], gensym_bindings ++ [qq_ast]} + end + + {ast, ctx} + else + # Normal mode: produce Elixir runtime data + {transform_quasiquote(form, ctx), ctx} + end + end + + # --- Unquote --- + defp do_transform({:unquote, _meta, form}, ctx) do + do_transform(form, ctx) + end + + # --- Splice-unquote --- + defp do_transform({:splice_unquote, _meta, form}, ctx) do + do_transform(form, ctx) + end + + # --- Deref --- + defp do_transform({:deref, _meta, form}, ctx) do + {inner, ctx} = do_transform(form, ctx) + # deref: Agent.get(ref, fn s -> s end) + ast = {{:., [], [{:__aliases__, [alias: false], [:Agent]}, :get]}, [], + [inner, {:fn, [], [{:->, [], [[{:s, [], nil}], {:s, [], nil}]}]}]} + {ast, ctx} + end + + # Catch-all for unknown forms + defp do_transform(other, ctx) do + {other, ctx} + end + + # --------------------------------------------------------------------------- + # Symbol transformation + # --------------------------------------------------------------------------- + + # Dynamic vars + defp transform_symbol("*self*", _meta, ctx) do + {{:self, [], []}, ctx} + end + + defp transform_symbol("*node*", _meta, ctx) do + {{:node, [], []}, ctx} + end + + # true/false/nil are also possible as symbols + defp transform_symbol("true", _meta, ctx), do: {true, ctx} + defp transform_symbol("false", _meta, ctx), do: {false, ctx} + defp transform_symbol("nil", _meta, ctx), do: {nil, ctx} + + # Module-qualified symbol like Enum/map or io/format + defp transform_symbol(name, _meta, ctx) when is_binary(name) do + cond do + String.contains?(name, "/") -> + transform_module_call_symbol(name, ctx) + + module_reference?(name) -> + # Bare module reference (e.g., CljElixir.SubVector as a value) + {parse_module_name(name), ctx} + + true -> + # Plain variable + munged = munge_name(name) + atom_name = String.to_atom(munged) + {{atom_name, [], nil}, ctx} + end + end + + # Detect bare module references: uppercase start with dots (e.g., CljElixir.SubVector) + defp module_reference?(name) do + first_char = String.first(name) + + first_char == String.upcase(first_char) and + first_char != String.downcase(first_char) and + String.contains?(name, ".") + end + + defp transform_module_call_symbol(name, ctx) do + {mod_ast, fun_atom} = parse_module_function(name) + # Bare qualified symbol → zero-arg call (e.g., Enum/count as value) + ast = {{:., [], [mod_ast, fun_atom]}, [], []} + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # List (call) transformation — the core dispatch + # --------------------------------------------------------------------------- + + defp transform_list([], _meta, ctx) do + {[], ctx} + end + + defp transform_list([head | args], meta, ctx) do + case head do + # --- Special forms (symbols) --- + {:symbol, _, "defmodule"} -> transform_defmodule(args, meta, ctx) + {:symbol, _, "defn"} -> transform_defn(args, meta, ctx, :def) + {:symbol, _, "defn-"} -> transform_defn(args, meta, ctx, :defp) + {:symbol, _, "fn"} -> transform_fn(args, meta, ctx) + {:symbol, _, "let"} -> transform_let(args, meta, ctx) + {:symbol, _, "if"} -> transform_if(args, meta, ctx) + {:symbol, _, "when"} -> transform_when(args, meta, ctx) + {:symbol, _, "cond"} -> transform_cond(args, meta, ctx) + {:symbol, _, "case"} -> transform_case(args, meta, ctx) + {:symbol, _, "do"} -> transform_do(args, meta, ctx) + {:symbol, _, "loop"} -> transform_loop(args, meta, ctx) + {:symbol, _, "recur"} -> transform_recur(args, meta, ctx) + {:symbol, _, "def"} -> transform_def(args, meta, ctx) + {:symbol, _, "defprotocol"} -> transform_defprotocol(args, meta, ctx) + {:symbol, _, "defrecord"} -> transform_defrecord(args, meta, ctx) + {:symbol, _, "extend-type"} -> transform_extend_type(args, meta, ctx) + {:symbol, _, "extend-protocol"} -> transform_extend_protocol(args, meta, ctx) + {:symbol, _, "reify"} -> transform_reify(args, meta, ctx) + {:symbol, _, "with"} -> transform_with(args, meta, ctx) + {:symbol, _, "receive"} -> transform_receive(args, meta, ctx) + {:symbol, _, "monitor"} -> transform_monitor(args, meta, ctx) + {:symbol, _, "link"} -> transform_link(args, meta, ctx) + {:symbol, _, "unlink"} -> transform_unlink(args, meta, ctx) + {:symbol, _, "alive?"} -> transform_alive?(args, meta, ctx) + {:symbol, _, "for"} -> transform_for(args, meta, ctx) + {:symbol, _, "doseq"} -> transform_doseq(args, meta, ctx) + {:symbol, _, "if-let"} -> transform_if_let(args, meta, ctx) + {:symbol, _, "when-let"} -> transform_when_let(args, meta, ctx) + {:symbol, _, "if-some"} -> transform_if_some(args, meta, ctx) + {:symbol, _, "when-some"} -> transform_when_some(args, meta, ctx) + {:symbol, _, "use"} -> transform_use(args, meta, ctx) + {:symbol, _, "require"} -> transform_require(args, meta, ctx) + {:symbol, _, "import"} -> transform_import(args, meta, ctx) + {:symbol, _, "alias"} -> transform_alias(args, meta, ctx) + {:symbol, _, "quote"} -> transform_quote_form(args, meta, ctx) + {:symbol, _, "defmacro"} -> transform_defmacro(args, meta, ctx) + + # --- Phase 7: Malli schema annotations --- + {:symbol, _, "m/=>"} -> transform_schema_spec(args, meta, ctx) + + # --- Operators and builtins --- + {:symbol, _, "+"} -> transform_arith(:+, args, ctx) + {:symbol, _, "-"} -> transform_arith(:-, args, ctx) + {:symbol, _, "*"} -> transform_arith(:*, args, ctx) + {:symbol, _, "/"} -> transform_arith(:/, args, ctx) + {:symbol, _, ">"} -> transform_comparison(:>, args, ctx) + {:symbol, _, "<"} -> transform_comparison(:<, args, ctx) + {:symbol, _, ">="} -> transform_comparison(:>=, args, ctx) + {:symbol, _, "<="} -> transform_comparison(:<=, args, ctx) + {:symbol, _, "="} -> transform_equality(args, ctx) + {:symbol, _, "=="} -> transform_numeric_equality(args, ctx) + {:symbol, _, "not="} -> transform_not_equal(args, ctx) + {:symbol, _, "!="} -> transform_not_equal(args, ctx) + {:symbol, _, "not"} -> transform_not(args, ctx) + {:symbol, _, "and"} -> transform_bool_op(:and, args, ctx) + {:symbol, _, "or"} -> transform_bool_op(:or, args, ctx) + {:symbol, _, "inc"} -> transform_inc(args, ctx) + {:symbol, _, "dec"} -> transform_dec(args, ctx) + {:symbol, _, "str"} -> transform_str(args, ctx) + {:symbol, _, "println"} -> transform_println(args, ctx) + {:symbol, _, "nil?"} -> transform_nil_check(args, ctx) + {:symbol, _, "throw"} -> transform_throw(args, ctx) + {:symbol, _, "count"} -> transform_count(args, ctx) + {:symbol, _, "hd"} -> transform_hd(args, ctx) + {:symbol, _, "tl"} -> transform_tl(args, ctx) + {:symbol, _, "cons"} -> transform_cons(args, ctx) + + # --- Phase 2: Protocol-backed core functions --- + {:symbol, _, "get"} -> transform_get(args, ctx) + {:symbol, _, "assoc"} -> transform_assoc(args, ctx) + {:symbol, _, "dissoc"} -> transform_dissoc(args, ctx) + {:symbol, _, "update"} -> transform_update(args, ctx) + {:symbol, _, "conj"} -> transform_conj(args, ctx) + {:symbol, _, "contains?"} -> transform_contains(args, ctx) + {:symbol, _, "empty?"} -> transform_empty(args, ctx) + {:symbol, _, "nth"} -> transform_nth(args, ctx) + {:symbol, _, "first"} -> transform_first(args, ctx) + {:symbol, _, "rest"} -> transform_rest(args, ctx) + {:symbol, _, "seq"} -> transform_seq(args, ctx) + {:symbol, _, "reduce"} -> transform_reduce(args, ctx) + {:symbol, _, "reduce-kv"} -> transform_reduce_kv(args, ctx) + {:symbol, _, "map"} -> transform_map_fn(args, ctx) + {:symbol, _, "filter"} -> transform_filter(args, ctx) + {:symbol, _, "concat"} -> transform_concat(args, ctx) + {:symbol, _, "take"} -> transform_take(args, ctx) + {:symbol, _, "drop"} -> transform_drop(args, ctx) + {:symbol, _, "sort"} -> transform_sort(args, ctx) + {:symbol, _, "sort-by"} -> transform_sort_by(args, ctx) + {:symbol, _, "group-by"} -> transform_group_by(args, ctx) + {:symbol, _, "frequencies"} -> transform_frequencies(args, ctx) + {:symbol, _, "distinct"} -> transform_distinct(args, ctx) + {:symbol, _, "mapcat"} -> transform_mapcat(args, ctx) + {:symbol, _, "partition"} -> transform_partition(args, ctx) + {:symbol, _, "keys"} -> transform_keys(args, ctx) + {:symbol, _, "vals"} -> transform_vals(args, ctx) + {:symbol, _, "merge"} -> transform_merge(args, ctx) + {:symbol, _, "select-keys"} -> transform_select_keys(args, ctx) + {:symbol, _, "into"} -> transform_into(args, ctx) + {:symbol, _, "get-in"} -> transform_get_in(args, ctx) + {:symbol, _, "assoc-in"} -> transform_assoc_in(args, ctx) + {:symbol, _, "update-in"} -> transform_update_in(args, ctx) + {:symbol, _, "list"} -> transform_list_call(args, ctx) + + # --- Phase 3: Vector builtins --- + {:symbol, _, "vec"} -> transform_vec(args, ctx) + {:symbol, _, "vector"} -> transform_vector_call(args, ctx) + {:symbol, _, "subvec"} -> transform_subvec_call(args, ctx) + {:symbol, _, "peek"} -> transform_peek(args, ctx) + {:symbol, _, "pop"} -> transform_pop(args, ctx) + {:symbol, _, "vector?"} -> transform_vector_check(args, ctx) + + # --- Phase 4: Domain tools --- + {:symbol, _, "tuple"} -> transform_tuple_fn(args, ctx) + {:symbol, _, "clojurify"} -> transform_clojurify(args, ctx) + {:symbol, _, "elixirify"} -> transform_elixirify(args, ctx) + + # --- Phase 6: Threading macros --- + {:symbol, _, "->"} -> transform_thread_first(args, meta, ctx) + {:symbol, _, "->>"} -> transform_thread_last(args, meta, ctx) + + # --- Phase 6: Exception handling --- + {:symbol, _, "try"} -> transform_try(args, meta, ctx) + + # --- Keyword-as-function: (:name user) --- + kw when is_atom(kw) -> + transform_keyword_call(kw, args, ctx) + + # --- Module/function calls from symbol with / --- + {:symbol, _, name} when is_binary(name) -> + if Map.has_key?(ctx.macros, name) do + expand_macro(name, args, ctx) + else + if String.contains?(name, "/") do + transform_module_call(name, args, ctx) + else + # Check for ->Constructor and map->Constructor + cond do + String.starts_with?(name, "->") and not String.starts_with?(name, "->>") -> + transform_positional_constructor(name, args, ctx) + + String.starts_with?(name, "map->") -> + transform_map_constructor(name, args, ctx) + + true -> + transform_unqualified_call(name, args, ctx) + end + end + end + + # If head is itself a list (e.g., ((fn [x] x) 42)), transform and call + _ -> + {head_ast, ctx} = do_transform(head, ctx) + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + {{:., [], [head_ast]} |> then(fn dot -> {dot, [], t_args} end), ctx} + end + end + + # --------------------------------------------------------------------------- + # 1. defmodule + # --------------------------------------------------------------------------- + + defp transform_defmodule(args, _meta, ctx) do + {name_form, rest} = extract_name(args) + mod_alias = module_name_ast(name_form) + + # Check for docstring + {moduledoc, body_forms} = + case rest do + [doc | body] when is_binary(doc) -> + {doc, body} + + _ -> + {nil, rest} + end + + new_ctx = %{ctx | module_name: mod_alias} + + {body_asts, _final_ctx} = + Enum.map_reduce(body_forms, new_ctx, fn form, acc -> + {ast, new_acc} = transform_form(form, acc) + {ast, new_acc} + end) + + # Filter out nil (from defmacro which produces no runtime code) + body_asts = Enum.filter(body_asts, &(&1 != nil)) + + doc_ast = + if moduledoc do + [{:@, [], [{:moduledoc, [], [moduledoc]}]}] + else + [] + end + + inner = doc_ast ++ body_asts + + block = + case inner do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + ast = + {:defmodule, [context: Elixir], + [mod_alias, [do: block]]} + + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 2. defn / defn- + # --------------------------------------------------------------------------- + + defp transform_defn(args, _meta, ctx, kind) do + {name_form, rest} = extract_name(args) + fun_name = symbol_to_atom(name_form) + + # Check for docstring + {doc, rest} = + case rest do + [d | r] when is_binary(d) -> {d, r} + _ -> {nil, rest} + end + + # Determine: single-arity vs multi-clause/multi-arity + # Multi-clause: rest is a list of lists, each starting with a vector + clauses = parse_defn_clauses(rest) + + def_kind = if kind == :def, do: :def, else: :defp + + doc_ast = + if doc do + [{:@, [], [{:doc, [], [doc]}]}] + else + [] + end + + clause_asts = + Enum.map(clauses, fn {params_vec, rest_param, guard, body_forms} -> + # Arity includes the rest param slot if present + arity = length(params_vec) + if(rest_param, do: 1, else: 0) + fn_ctx = %{ctx | function_name: fun_name, function_arity: arity, in_pattern: false} + pattern_ctx = %{fn_ctx | in_pattern: true} + + param_asts = Enum.map(params_vec, fn p -> transform(p, pattern_ctx) end) + + # If there's a rest param, add it as a parameter with default \\ [] + param_asts = + case rest_param do + nil -> + param_asts + + rest_sym -> + rest_var_ast = transform(rest_sym, pattern_ctx) + rest_with_default = {:"\\\\", [], [rest_var_ast, []]} + param_asts ++ [rest_with_default] + end + + body_ast = transform_body(body_forms, fn_ctx) + + clause = + case guard do + nil -> + {def_kind, [], [call_with_args(fun_name, param_asts), [do: body_ast]]} + + guard_form -> + guard_ast = transform(guard_form, fn_ctx) + + {def_kind, [], + [ + {:when, [], + [call_with_args(fun_name, param_asts), guard_ast]}, + [do: body_ast] + ]} + end + + clause + end) + + result = doc_ast ++ clause_asts + + ast = + case result do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + {ast, ctx} + end + + defp parse_defn_clauses(rest) do + case rest do + # Single-arity: (defn f [params] body...) + [{:vector, _, _} = params_vec | body] -> + params = vector_elements(params_vec) + {required, rest_param} = split_rest_params(params) + [{required, rest_param, nil, body}] + + # Multi-clause: (defn f ([params1] body1) ([params2] body2) ...) + clauses when is_list(clauses) -> + Enum.map(clauses, fn + {:list, _, [{:vector, _, params} | body]} -> + {required, rest_param} = split_rest_params(params) + {required, rest_param, nil, body} + + {:list, _, clause_elements} -> + # Might have guard: ([params] :when guard body) + parse_clause_with_guard(clause_elements) + end) + end + end + + defp parse_clause_with_guard([{:vector, _, params}, :when, guard | body]) do + {required, rest_param} = split_rest_params(params) + {required, rest_param, guard, body} + end + + defp parse_clause_with_guard([{:vector, _, params} | body]) do + {required, rest_param} = split_rest_params(params) + {required, rest_param, nil, body} + end + + # Split a param list at `&` into {required_params, rest_param | nil} + # e.g. [a b & rest] → {[a, b], rest_symbol} + defp split_rest_params(params) do + case Enum.split_while(params, fn + {:symbol, _, "&"} -> false + _ -> true + end) do + {required, [{:symbol, _, "&"}, rest_param]} -> + {required, rest_param} + + {required, [{:symbol, _, "&"}, rest_param | _extra]} -> + {required, rest_param} + + {all, []} -> + {all, nil} + end + end + + # --------------------------------------------------------------------------- + # 3. fn + # --------------------------------------------------------------------------- + + defp transform_fn(args, _meta, ctx) do + clauses = parse_fn_clauses(args) + + fn_clauses = + Enum.flat_map(clauses, fn {params, rest_param, guard, body_forms} -> + pattern_ctx = %{ctx | in_pattern: true} + param_asts = Enum.map(params, fn p -> transform(p, pattern_ctx) end) + body_ast = transform_body(body_forms, ctx) + + # Build the param list, adding rest param as a regular parameter if present + all_param_asts = + case rest_param do + nil -> param_asts + rest_sym -> + rest_var_ast = transform(rest_sym, pattern_ctx) + param_asts ++ [rest_var_ast] + end + + clause = + case guard do + nil -> + {:->, [], [all_param_asts, body_ast]} + + guard_form -> + guard_ast = transform(guard_form, ctx) + guard_params = [{:when, [], all_param_asts ++ [guard_ast]}] + {:->, [], [guard_params, body_ast]} + end + + [clause] + end) + + {{:fn, [], fn_clauses}, ctx} + end + + defp parse_fn_clauses(args) do + case args do + # Single-arity: (fn [params] body...) + [{:vector, _, params} | body] -> + {required, rest_param} = split_rest_params(params) + [{required, rest_param, nil, body}] + + # Multi-arity: (fn ([p1] b1) ([p2 p3] b2) ...) + clauses -> + Enum.map(clauses, fn + {:list, _, [{:vector, _, params} | body]} -> + {required, rest_param} = split_rest_params(params) + {required, rest_param, nil, body} + + {:list, _, clause_elements} -> + parse_clause_with_guard(clause_elements) + end) + end + end + + # --------------------------------------------------------------------------- + # 4. #() anonymous shorthand + # --------------------------------------------------------------------------- + + defp transform_anon_fn(body, ctx) do + # Walk body to find %1, %2, ... or % (= %1) + max_arg = find_max_anon_arg(body) + arity = max(max_arg, 1) + + params = + Enum.map(1..arity, fn i -> + {String.to_atom("p#{i}"), [], nil} + end) + + # Replace % / %1 / %2 etc in body with p1, p2 etc + transformed_body = replace_anon_args(body) + body_ast = transform(transformed_body, ctx) + + fn_ast = {:fn, [], [{:->, [], [params, body_ast]}]} + {fn_ast, ctx} + end + + defp find_max_anon_arg(form) do + case form do + {:symbol, _, "%"} -> 1 + {:symbol, _, "%" <> rest} -> + case Integer.parse(rest) do + {n, ""} -> n + _ -> 0 + end + + {:list, _, elements} -> + elements |> Enum.map(&find_max_anon_arg/1) |> Enum.max(fn -> 0 end) + + {:vector, _, elements} -> + elements |> Enum.map(&find_max_anon_arg/1) |> Enum.max(fn -> 0 end) + + {:map, _, pairs} -> + pairs |> Enum.map(&find_max_anon_arg/1) |> Enum.max(fn -> 0 end) + + {:set, _, elements} -> + elements |> Enum.map(&find_max_anon_arg/1) |> Enum.max(fn -> 0 end) + + {:tuple, _, elements} -> + elements |> Enum.map(&find_max_anon_arg/1) |> Enum.max(fn -> 0 end) + + _ -> 0 + end + end + + defp replace_anon_args(form) do + case form do + {:symbol, meta, "%"} -> + {:symbol, meta, "p1"} + + {:symbol, meta, "%" <> rest} -> + case Integer.parse(rest) do + {_n, ""} -> {:symbol, meta, "p#{rest}"} + _ -> form + end + + {:list, meta, elements} -> + {:list, meta, Enum.map(elements, &replace_anon_args/1)} + + {:vector, meta, elements} -> + {:vector, meta, Enum.map(elements, &replace_anon_args/1)} + + {:map, meta, pairs} -> + {:map, meta, Enum.map(pairs, &replace_anon_args/1)} + + {:set, meta, elements} -> + {:set, meta, Enum.map(elements, &replace_anon_args/1)} + + {:tuple, meta, elements} -> + {:tuple, meta, Enum.map(elements, &replace_anon_args/1)} + + other -> other + end + end + + # --------------------------------------------------------------------------- + # 5. let + # --------------------------------------------------------------------------- + + defp transform_let([{:vector, _, bindings} | body], _meta, ctx) do + binding_pairs = Enum.chunk_every(bindings, 2) + + {binding_asts, final_ctx} = + Enum.map_reduce(binding_pairs, ctx, fn [pattern, expr], acc -> + pattern_ctx = %{acc | in_pattern: true} + pat_ast = transform(pattern, pattern_ctx) + expr_ast = transform(expr, acc) + match_ast = {:=, [], [pat_ast, expr_ast]} + {match_ast, acc} + end) + + body_ast = transform_body(body, final_ctx) + all = binding_asts ++ [body_ast] + + ast = + case all do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 6. if / when / cond / case / do + # --------------------------------------------------------------------------- + + defp transform_if([test, then_form | else_form], _meta, ctx) do + test_ast = transform(test, ctx) + then_ast = transform(then_form, ctx) + + else_ast = + case else_form do + [e] -> transform(e, ctx) + [] -> nil + end + + ast = {:if, [], [test_ast, [do: then_ast, else: else_ast]]} + {ast, ctx} + end + + defp transform_when([test | body], _meta, ctx) do + test_ast = transform(test, ctx) + body_ast = transform_body(body, ctx) + ast = {:if, [], [test_ast, [do: body_ast]]} + {ast, ctx} + end + + defp transform_cond(pairs, _meta, ctx) do + clauses = + pairs + |> Enum.chunk_every(2) + |> Enum.map(fn [condition, result] -> + cond_ast = + case condition do + :else -> true + _ -> transform(condition, ctx) + end + + result_ast = transform(result, ctx) + {:->, [], [[cond_ast], result_ast]} + end) + + ast = {:cond, [], [[do: clauses]]} + {ast, ctx} + end + + defp transform_case([val | clauses], _meta, ctx) do + val_ast = transform(val, ctx) + pattern_ctx = %{ctx | in_pattern: true} + + case_clauses = + clauses + |> Enum.chunk_every(2) + |> Enum.map(fn + [pattern, :when | rest] -> + # pattern :when guard body — need to re-chunk + # This won't happen with chunk_every(2), handle differently + pat_ast = transform(pattern, pattern_ctx) + body_ast = transform(List.last(rest), ctx) + {:->, [], [[pat_ast], body_ast]} + + [pattern, body] -> + pat_ast = transform(pattern, pattern_ctx) + body_ast = transform(body, ctx) + {:->, [], [[pat_ast], body_ast]} + end) + + ast = {:case, [], [val_ast, [do: case_clauses]]} + {ast, ctx} + end + + defp transform_do(exprs, _meta, ctx) do + body_ast = transform_body(exprs, ctx) + {body_ast, ctx} + end + + # --------------------------------------------------------------------------- + # 7. loop / recur + # --------------------------------------------------------------------------- + + defp transform_loop([{:vector, _, bindings} | body], _meta, ctx) do + binding_pairs = Enum.chunk_every(bindings, 2) + arity = length(binding_pairs) + + loop_var = unique_var(:loop_fn, ctx) + loop_ctx = %{ctx | loop_var: loop_var, loop_arity: arity} + + {param_names, init_vals} = + Enum.map(binding_pairs, fn [name, val] -> + pattern_ctx = %{loop_ctx | in_pattern: true} + {transform(name, pattern_ctx), transform(val, ctx)} + end) + |> Enum.unzip() + + body_ast = transform_body(body, loop_ctx) + + # Pattern: loop_fn = fn loop_fn, bindings... -> body end; loop_fn.(loop_fn, init_vals...) + fn_params = [loop_var | param_names] + fn_ast = {:fn, [], [{:->, [], [fn_params, body_ast]}]} + + assign = {:=, [], [loop_var, fn_ast]} + invoke = {{:., [], [loop_var]}, [], [loop_var | init_vals]} + + ast = {:__block__, [], [assign, invoke]} + {ast, ctx} + end + + defp transform_recur(args, _meta, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + + ast = + if ctx.loop_var do + # recur in loop: call the loop fn with itself + new args + {{:., [], [ctx.loop_var]}, [], [ctx.loop_var | t_args]} + else + # recur in defn: call the current function recursively + if ctx.function_name do + {ctx.function_name, [], t_args} + else + raise "recur outside of loop or defn" + end + end + + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 8. def (top-level binding) + # --------------------------------------------------------------------------- + + defp transform_def([name_form, value], _meta, ctx) do + fun_name = symbol_to_atom(name_form) + + # Check if value looks like a schema definition + case detect_schema(value) do + nil -> + val_ast = transform(value, ctx) + def_ast = {:def, [], [{fun_name, [], []}, [do: val_ast]]} + {def_ast, ctx} + + schema_data -> + # For schema defs, use the plain data as the runtime value + # (avoids trying to transform schema references like PositiveInt as variables) + val_ast = Macro.escape(schema_data) + def_ast = {:def, [], [{fun_name, [], []}, [do: val_ast]]} + + # Generate type name: "User" -> :user, "PositiveInt" -> :positive_int + schema_name = symbol_name(name_form) + type_name = schema_name_to_type_atom(schema_name) + + # Store in context for cross-references + new_schemas = Map.put(ctx.schemas, schema_name, type_name) + new_ctx = %{ctx | schemas: new_schemas} + + # Generate @type AST + opts = [known_types: new_schemas] + type_asts = case schema_data do + [:schema, %{registry: _} | _] -> + CljElixir.Malli.type_ast(type_name, schema_data, opts) + _ -> + [CljElixir.Malli.type_ast(type_name, schema_data, opts)] + end + + # Return block: @type(s) + def + all = type_asts ++ [def_ast] + ast = {:__block__, [], all} + {ast, new_ctx} + end + end + + defp transform_def([name_form], _meta, ctx) do + fun_name = symbol_to_atom(name_form) + ast = {:def, [], [{fun_name, [], []}, [do: nil]]} + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 9. Module/function calls (FFI) + # --------------------------------------------------------------------------- + + defp transform_module_call(name, args, ctx) do + {mod_ast, fun_atom} = parse_module_function(name) + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + ast = {{:., [], [mod_ast, fun_atom]}, [], t_args} + {ast, ctx} + end + + defp parse_module_function(name) do + # Split on first / only (module part may contain dots) + {mod_str, fun_str} = + case String.split(name, "/", parts: 2) do + [m, f] -> {m, f} + [m] -> {m, nil} + end + + fun_atom = if fun_str, do: String.to_atom(munge_ffi_name(fun_str)), else: nil + + mod_ast = parse_module_name(mod_str) + + {mod_ast, fun_atom} + end + + defp parse_module_name(mod_str) do + first_char = String.first(mod_str) + + if first_char == String.upcase(first_char) and first_char != String.downcase(first_char) do + # Uppercase = Elixir module + # Could be dotted like MyApp.Greeter + parts = String.split(mod_str, ".") + atoms = Enum.map(parts, &String.to_atom/1) + {:__aliases__, [alias: false], atoms} + else + # Lowercase = Erlang module + String.to_atom(mod_str) + end + end + + # --------------------------------------------------------------------------- + # 10. Unqualified function calls + # --------------------------------------------------------------------------- + + defp transform_unqualified_call(name, args, ctx) do + munged = munge_name(name) + fun_atom = String.to_atom(munged) + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + ast = {fun_atom, [], t_args} + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 11. Data literals + # --------------------------------------------------------------------------- + + defp transform_vector(elements, _meta, ctx) do + if ctx.in_pattern do + pattern_ctx = %{ctx | in_pattern: true} + + if has_ampersand?(elements) do + # Sequential destructuring: [a b & rest] → list cons pattern [a, b | rest] + transform_sequential_destructuring(elements, pattern_ctx, ctx) + else + # Normal pattern position: vectors become tuple matches + transformed = Enum.map(elements, fn e -> transform(e, pattern_ctx) end) + {{:{}, [], transformed}, ctx} + end + else + transformed = Enum.map(elements, fn e -> transform(e, ctx) end) + + if ctx.vector_as_list do + # Runtime .clje files: vectors stay as Elixir lists (bootstrap mode) + {transformed, ctx} + else + # User code: vectors become PersistentVector + pv_mod = {:__aliases__, [alias: false], [:CljElixir, :PersistentVector]} + ast = {{:., [], [pv_mod, :from_list]}, [], [transformed]} + {ast, ctx} + end + end + end + + # Check if elements contain an ampersand symbol (for sequential destructuring) + defp has_ampersand?(elements) do + Enum.any?(elements, fn + {:symbol, _, "&"} -> true + _ -> false + end) + end + + # Transform sequential destructuring: [a b & rest] → [a, b | rest] + # Also supports :as: [a b & rest :as all] + defp transform_sequential_destructuring(elements, pattern_ctx, ctx) do + {before_amp, after_amp} = + Enum.split_while(elements, fn + {:symbol, _, "&"} -> false + _ -> true + end) + + # after_amp starts with & symbol + [_amp | rest_and_maybe_as] = after_amp + + {rest_sym, as_binding} = + case rest_and_maybe_as do + [rest_s, :as, as_s] -> {rest_s, as_s} + [rest_s] -> {rest_s, nil} + end + + # Transform required elements + required = Enum.map(before_amp, fn e -> transform(e, pattern_ctx) end) + + # Transform rest binding + rest_var = transform(rest_sym, pattern_ctx) + + # Build [a, b | rest] as Elixir AST. + # Elixir represents [a, b | rest] as: [a, {:|, [], [b, rest]}] + # i.e., a proper list where the last element is a {:|, [], [head, tail]} tuple. + list_pattern = build_cons_list_ast(required, rest_var) + + # Handle :as if present + case as_binding do + nil -> + {list_pattern, ctx} + + as_sym -> + as_var = transform(as_sym, pattern_ctx) + {{:=, [], [list_pattern, as_var]}, ctx} + end + end + + # Build [a, b | rest] in Elixir AST format. + # [a, b | rest] is represented as [a, {:|, [], [b, rest]}] + # [a | rest] is represented as [{:|, [], [a, rest]}] + # [| rest] with no heads is just rest itself + defp build_cons_list_ast([], rest_var) do + # No required elements, just the rest — but this shouldn't normally happen + # with & destructuring. If it does, treat as the rest variable itself. + rest_var + end + + defp build_cons_list_ast(required, rest_var) do + {heads, [last]} = Enum.split(required, -1) + heads ++ [{:|, [], [last, rest_var]}] + end + + defp transform_map(pairs, ctx) do + if ctx.in_pattern do + transform_destructuring_map(pairs, ctx) + else + kv_pairs = + pairs + |> Enum.chunk_every(2) + |> Enum.map(fn [k, v] -> + k_ast = transform(k, ctx) + v_ast = transform(v, ctx) + {k_ast, v_ast} + end) + + ast = {:%{}, [], kv_pairs} + {ast, ctx} + end + end + + # Transform a map in pattern position as Clojure-style destructuring. + # pairs is the flat list [k1, v1, k2, v2, ...] from {:map, meta, pairs} + # + # Clojure destructuring conventions: + # - :keys [a b] → atom keys :a, :b with bindings a, b + # - :strs [a b] → string keys "a", "b" with bindings a, b + # - :as name → also bind the whole map to name + # - {binding key ...} → literal pairs: first is binding form, second is lookup key + defp transform_destructuring_map(pairs, ctx) do + kv_chunks = Enum.chunk_every(pairs, 2) + + {keys_bindings, strs_bindings, as_binding, literal_pairs} = + parse_destructuring_directives(kv_chunks) + + pattern_pairs = [] + + # :keys -> atom keys + pattern_pairs = + pattern_pairs ++ + Enum.map(keys_bindings, fn name -> + # Key atom preserves hyphens (per spec), variable is munged + atom_key = String.to_atom(name) + var_name = String.to_atom(munge_name(name)) + var = {var_name, [], nil} + {atom_key, var} + end) + + # :strs -> string keys + pattern_pairs = + pattern_pairs ++ + Enum.map(strs_bindings, fn name -> + var_name = String.to_atom(munge_name(name)) + var = {var_name, [], nil} + {name, var} + end) + + # Literal pairs: Clojure convention is {binding key}, so first=binding, second=key + pattern_pairs = + pattern_pairs ++ + Enum.map(literal_pairs, fn [binding_form, key_form] -> + # key_form is the map key (atom, string, etc.) - transform without pattern context + k_ast = transform(key_form, %{ctx | in_pattern: false}) + # binding_form is the binding pattern (symbol or nested destructuring) + v_ast = transform(binding_form, ctx) + {k_ast, v_ast} + end) + + map_ast = {:%{}, [], pattern_pairs} + + # Wrap with :as if present + case as_binding do + nil -> + {map_ast, ctx} + + as_name -> + as_var = {String.to_atom(munge_name(as_name)), [], nil} + {{:=, [], [map_ast, as_var]}, ctx} + end + end + + # Parse destructuring directives from chunked kv pairs. + # Returns {keys_names, strs_names, as_name | nil, literal_pairs} + defp parse_destructuring_directives(kv_chunks) do + Enum.reduce(kv_chunks, {[], [], nil, []}, fn + [:keys, {:vector, _, symbols}], {keys, strs, as_b, lits} -> + names = Enum.map(symbols, fn {:symbol, _, n} -> n end) + {keys ++ names, strs, as_b, lits} + + [:strs, {:vector, _, symbols}], {keys, strs, as_b, lits} -> + names = Enum.map(symbols, fn {:symbol, _, n} -> n end) + {keys, strs ++ names, as_b, lits} + + [:as, {:symbol, _, name}], {keys, strs, _as_b, lits} -> + {keys, strs, name, lits} + + other, {keys, strs, as_b, lits} -> + {keys, strs, as_b, lits ++ [other]} + end) + end + + defp transform_set(elements, ctx) do + transformed = Enum.map(elements, fn e -> transform(e, ctx) end) + ast = {{:., [], [{:__aliases__, [alias: false], [:MapSet]}, :new]}, [], [transformed]} + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 12. Keyword-as-function + # --------------------------------------------------------------------------- + + defp transform_keyword_call(kw, args, ctx) do + case args do + [map_form] -> + map_ast = transform(map_form, ctx) + ast = protocol_call([:CljElixir, :ILookup], :lookup, [map_ast, kw]) + {ast, ctx} + + [map_form, default] -> + map_ast = transform(map_form, ctx) + default_ast = transform(default, ctx) + ast = protocol_call([:CljElixir, :ILookup], :lookup, [map_ast, kw, default_ast]) + {ast, ctx} + + _ -> + # Just return the keyword itself if no args (shouldn't happen) + {kw, ctx} + end + end + + # --------------------------------------------------------------------------- + # 13. defprotocol + # --------------------------------------------------------------------------- + + defp transform_defprotocol(args, _meta, ctx) do + {name_form, rest} = extract_name(args) + proto_alias = module_name_ast(name_form) + + # Optional docstring + {doc, fn_sigs} = + case rest do + [d | sigs] when is_binary(d) -> {d, sigs} + _ -> {nil, rest} + end + + doc_ast = + if doc do + [{:@, [], [{:moduledoc, [], [doc]}]}] + else + [] + end + + sig_asts = + Enum.map(fn_sigs, fn + {:list, _, [{:symbol, _, fname} | sig_args]} -> + fun_atom = String.to_atom(munge_name(strip_leading_dash(fname))) + + # Each sig_arg is a vector with the params pattern + param_lists = + Enum.map(sig_args, fn + {:vector, _, params} -> params + # Might also have a docstring after the vector + _other -> nil + end) + |> Enum.filter(&is_list/1) + + # Generate def heads for each arity + Enum.map(param_lists, fn params -> + param_asts = Enum.map(params, fn p -> transform(p, ctx) end) + {:def, [], [{fun_atom, [], param_asts}]} + end) + + _ -> [] + end) + |> List.flatten() + + inner = doc_ast ++ sig_asts + + block = + case inner do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + ast = {:defprotocol, [context: Elixir], [proto_alias, [do: block]]} + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 14. defrecord + # --------------------------------------------------------------------------- + + defp transform_defrecord(args, _meta, ctx) do + {name_form, rest} = extract_name(args) + record_alias = module_name_ast(name_form) + record_name = symbol_name(name_form) + + # Optional docstring + {doc, rest} = + case rest do + [d | r] when is_binary(d) -> {d, r} + _ -> {nil, rest} + end + + # Fields vector + {fields, proto_impls} = + case rest do + [{:vector, _, field_list} | impl_rest] -> + field_names = Enum.map(field_list, fn + {:symbol, _, n} -> String.to_atom(munge_name(n)) + a when is_atom(a) -> a + end) + {field_names, impl_rest} + + _ -> + {[], rest} + end + + # Track record in context + new_ctx = %{ctx | records: Map.put(ctx.records, record_name, fields)} + + doc_ast = + if doc do + [{:@, [], [{:moduledoc, [], [doc]}]}] + else + [] + end + + # defstruct + struct_ast = {:defstruct, [], [fields]} + + # Positional constructor: new/N + field_vars = Enum.map(fields, fn f -> {f, [], nil} end) + map_pairs = Enum.zip(fields, field_vars) |> Enum.map(fn {k, v} -> {k, v} end) + + new_fn_ast = + {:def, [], + [{:new, [], field_vars}, + [do: {:%, [], [{:__MODULE__, [], Elixir}, {:%{}, [], map_pairs}]}]]} + + # Separate defn/defn-/def forms from protocol implementations + {fn_forms, proto_forms} = + Enum.split_with(proto_impls, fn + {:list, _, [{:symbol, _, "defn"} | _]} -> true + {:list, _, [{:symbol, _, "defn-"} | _]} -> true + {:list, _, [{:symbol, _, "def"} | _]} -> true + _ -> false + end) + + # Transform function definitions inside the record module + fn_asts = + Enum.map(fn_forms, fn form -> + {ast, _ctx} = do_transform(form, new_ctx) + ast + end) + + # Protocol implementations + impl_asts = transform_inline_impls(proto_forms, record_alias, new_ctx) + + inner = doc_ast ++ [struct_ast, new_fn_ast] ++ fn_asts ++ impl_asts + + block = + case inner do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + ast = {:defmodule, [context: Elixir], [record_alias, [do: block]]} + {ast, new_ctx} + end + + defp transform_inline_impls(forms, _for_type, ctx) do + # Parse: ProtocolName (fn-name [params] body)... ProtocolName ... + # Use __MODULE__ so the impl resolves correctly even when the record is nested + parse_protocol_groups(forms) + |> Enum.flat_map(fn {proto_name, fns} -> + proto_alias = module_name_ast(proto_name) + fn_asts = transform_protocol_fns(fns, ctx) + + block = + case fn_asts do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + [{:defimpl, [context: Elixir], [proto_alias, [for: {:__MODULE__, [], Elixir}], [do: block]]}] + end) + end + + # --------------------------------------------------------------------------- + # 15. extend-type / extend-protocol + # --------------------------------------------------------------------------- + + defp transform_extend_type([type_form | rest], _meta, ctx) do + type_alias = resolve_type_name(type_form) + + groups = parse_protocol_groups(rest) + + impls = + Enum.map(groups, fn {proto_name, fns} -> + proto_alias = module_name_ast(proto_name) + fn_asts = transform_protocol_fns(fns, ctx) + + block = + case fn_asts do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + {:defimpl, [context: Elixir], [proto_alias, [for: type_alias], [do: block]]} + end) + + ast = + case impls do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + {ast, ctx} + end + + defp transform_extend_protocol([proto_form | rest], _meta, ctx) do + proto_alias = resolve_type_name(proto_form) + + # Parse: TypeName (fn ...) TypeName (fn ...) + groups = parse_type_groups(rest) + + impls = + Enum.map(groups, fn {type_name, fns} -> + type_alias = resolve_type_name(type_name) + fn_asts = transform_protocol_fns(fns, ctx) + + block = + case fn_asts do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + {:defimpl, [context: Elixir], [proto_alias, [for: type_alias], [do: block]]} + end) + + ast = + case impls do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 16. reify + # --------------------------------------------------------------------------- + + defp transform_reify(args, _meta, ctx) do + {counter, new_ctx} = bump_gensym(ctx) + mod_name = String.to_atom("CljElixir.Reify_#{counter}") + mod_alias = {:__aliases__, [alias: false], [mod_name]} + + groups = parse_protocol_groups(args) + + impl_asts = + Enum.map(groups, fn {proto_name, fns} -> + proto_alias = module_name_ast(proto_name) + fn_asts = transform_protocol_fns(fns, new_ctx) + + block = + case fn_asts do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + {:defimpl, [context: Elixir], [proto_alias, [for: mod_alias], [do: block]]} + end) + + struct_ast = {:defstruct, [], [[]]} + + inner = + case [struct_ast | impl_asts] do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + defmod = {:defmodule, [context: Elixir], [mod_alias, [do: inner]]} + instance = {:%, [], [mod_alias, {:%{}, [], []}]} + + ast = {:__block__, [], [defmod, instance]} + {ast, new_ctx} + end + + # --------------------------------------------------------------------------- + # 17. with + # --------------------------------------------------------------------------- + + defp transform_with([{:vector, _, bindings} | body_and_else], _meta, ctx) do + binding_pairs = Enum.chunk_every(bindings, 2) + pattern_ctx = %{ctx | in_pattern: true} + + with_clauses = + Enum.map(binding_pairs, fn [pattern, expr] -> + pat_ast = transform(pattern, pattern_ctx) + expr_ast = transform(expr, ctx) + {:<-, [], [pat_ast, expr_ast]} + end) + + # Split body from :else + {body_forms, else_clauses} = split_with_else(body_and_else) + + body_ast = transform_body(body_forms, ctx) + + opts = + if else_clauses == [] do + [do: body_ast] + else + else_pairs = + else_clauses + |> Enum.chunk_every(2) + |> Enum.map(fn [pattern, result] -> + pat_ast = transform(pattern, pattern_ctx) + res_ast = transform(result, ctx) + {:->, [], [[pat_ast], res_ast]} + end) + + [do: body_ast, else: else_pairs] + end + + ast = {:with, [], with_clauses ++ [opts]} + {ast, ctx} + end + + defp split_with_else(forms) do + case Enum.split_while(forms, fn f -> f != :else end) do + {body, [:else | else_clauses]} -> {body, else_clauses} + {body, []} -> {body, []} + end + end + + # --------------------------------------------------------------------------- + # 18. receive + # --------------------------------------------------------------------------- + + defp transform_receive(clauses, _meta, ctx) do + pattern_ctx = %{ctx | in_pattern: true} + + {case_clauses, after_clause} = parse_receive_clauses(clauses) + + transformed_clauses = + Enum.map(case_clauses, fn + {pattern, guard, body} -> + pat_ast = transform(pattern, pattern_ctx) + body_ast = transform(body, ctx) + + case guard do + nil -> + {:->, [], [[pat_ast], body_ast]} + + guard_form -> + guard_ast = transform(guard_form, ctx) + {:->, [], [[{:when, [], [pat_ast, guard_ast]}], body_ast]} + end + end) + + opts = [do: transformed_clauses] + + opts = + case after_clause do + nil -> + opts + + {timeout, body} -> + timeout_ast = transform(timeout, ctx) + body_ast = transform(body, ctx) + opts ++ [after: [{:->, [], [[timeout_ast], body_ast]}]] + end + + ast = {:receive, [], [opts]} + {ast, ctx} + end + + defp parse_receive_clauses(forms) do + parse_receive_clauses(forms, [], nil) + end + + defp parse_receive_clauses([], acc, after_clause) do + {Enum.reverse(acc), after_clause} + end + + defp parse_receive_clauses([:after, timeout, body | rest], acc, _after) do + parse_receive_clauses(rest, acc, {timeout, body}) + end + + defp parse_receive_clauses([pattern, :when, guard, body | rest], acc, after_clause) do + parse_receive_clauses(rest, [{pattern, guard, body} | acc], after_clause) + end + + defp parse_receive_clauses([pattern, body | rest], acc, after_clause) do + parse_receive_clauses(rest, [{pattern, nil, body} | acc], after_clause) + end + + # --------------------------------------------------------------------------- + # 18b. Process primitives: monitor, link, unlink, alive? + # --------------------------------------------------------------------------- + + defp process_mod_ast do + {:__aliases__, [alias: false], [:Process]} + end + + # (monitor pid) → Process.monitor(pid) + # (monitor :process pid) → :erlang.monitor(:process, pid) + defp transform_monitor([pid], _meta, ctx) do + pid_ast = transform(pid, ctx) + ast = {{:., [], [process_mod_ast(), :monitor]}, [], [pid_ast]} + {ast, ctx} + end + + defp transform_monitor([type, pid], _meta, ctx) do + type_ast = transform(type, ctx) + pid_ast = transform(pid, ctx) + ast = {{:., [], [:erlang, :monitor]}, [], [type_ast, pid_ast]} + {ast, ctx} + end + + # (link pid) → Process.link(pid) + defp transform_link([pid], _meta, ctx) do + pid_ast = transform(pid, ctx) + ast = {{:., [], [process_mod_ast(), :link]}, [], [pid_ast]} + {ast, ctx} + end + + # (unlink pid) → Process.unlink(pid) + defp transform_unlink([pid], _meta, ctx) do + pid_ast = transform(pid, ctx) + ast = {{:., [], [process_mod_ast(), :unlink]}, [], [pid_ast]} + {ast, ctx} + end + + # (alive? pid) → Process.alive?(pid) + defp transform_alive?([pid], _meta, ctx) do + pid_ast = transform(pid, ctx) + ast = {{:., [], [process_mod_ast(), :alive?]}, [], [pid_ast]} + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 19. for / doseq + # --------------------------------------------------------------------------- + + defp transform_for([{:vector, _, bindings} | body], _meta, ctx) do + {generators, filters} = parse_comprehension_bindings(bindings, ctx) + body_ast = transform_body(body, ctx) + + args = generators ++ filters ++ [[do: body_ast]] + ast = {:for, [], args} + {ast, ctx} + end + + defp transform_doseq([{:vector, _, bindings} | body], _meta, ctx) do + {generators, filters} = parse_comprehension_bindings(bindings, ctx) + body_ast = transform_body(body, ctx) + + args = generators ++ filters ++ [[do: body_ast]] + ast = {:for, [], args} + {ast, ctx} + end + + defp parse_comprehension_bindings(bindings, ctx) do + parse_comp_bindings(bindings, ctx, [], []) + end + + defp parse_comp_bindings([], _ctx, gens, filters) do + {Enum.reverse(gens), Enum.reverse(filters)} + end + + defp parse_comp_bindings([:when, pred | rest], ctx, gens, filters) do + pred_ast = transform(pred, ctx) + parse_comp_bindings(rest, ctx, gens, [pred_ast | filters]) + end + + defp parse_comp_bindings([:let, {:vector, _, let_bindings} | rest], ctx, gens, filters) do + # :let bindings in for comprehension + pairs = Enum.chunk_every(let_bindings, 2) + let_asts = Enum.map(pairs, fn [p, e] -> + pattern_ctx = %{ctx | in_pattern: true} + {transform(p, pattern_ctx), transform(e, ctx)} + end) + parse_comp_bindings(rest, ctx, gens, filters ++ Enum.map(let_asts, fn {p, e} -> {:=, [], [p, e]} end)) + end + + defp parse_comp_bindings([pattern, coll | rest], ctx, gens, filters) do + pattern_ctx = %{ctx | in_pattern: true} + pat_ast = transform(pattern, pattern_ctx) + coll_ast = transform(coll, ctx) + gen = {:<-, [], [pat_ast, coll_ast]} + parse_comp_bindings(rest, ctx, [gen | gens], filters) + end + + # --------------------------------------------------------------------------- + # 20. if-let / when-let / if-some / when-some + # --------------------------------------------------------------------------- + + defp transform_if_let([{:vector, _, [pattern, expr]} | body], _meta, ctx) do + pattern_ctx = %{ctx | in_pattern: true} + pat_ast = transform(pattern, pattern_ctx) + expr_ast = transform(expr, ctx) + + {then_form, else_form} = + case body do + [t, e] -> {t, e} + [t] -> {t, nil} + end + + then_ast = transform(then_form, ctx) + else_ast = if else_form, do: transform(else_form, ctx), else: nil + + # Use a temporary variable to check truthiness (not nil and not false) + temp_var = {:clje_if_let_val, [], nil} + + ast = + {:__block__, [], + [ + {:=, [], [temp_var, expr_ast]}, + {:if, [], + [ + {:and, [], + [{:not, [], [{:is_nil, [], [temp_var]}]}, + {:!=, [], [temp_var, false]}]}, + [ + do: {:__block__, [], [{:=, [], [pat_ast, temp_var]}, then_ast]}, + else: else_ast + ] + ]} + ]} + + {ast, ctx} + end + + defp transform_when_let([{:vector, _, [pattern, expr]} | body], _meta, ctx) do + pattern_ctx = %{ctx | in_pattern: true} + pat_ast = transform(pattern, pattern_ctx) + expr_ast = transform(expr, ctx) + body_ast = transform_body(body, ctx) + + temp_var = {:clje_when_let_val, [], nil} + + ast = + {:__block__, [], + [ + {:=, [], [temp_var, expr_ast]}, + {:if, [], + [ + {:and, [], + [{:not, [], [{:is_nil, [], [temp_var]}]}, + {:!=, [], [temp_var, false]}]}, + [do: {:__block__, [], [{:=, [], [pat_ast, temp_var]}, body_ast]}] + ]} + ]} + + {ast, ctx} + end + + defp transform_if_some([{:vector, _, [pattern, expr]} | body], _meta, ctx) do + pattern_ctx = %{ctx | in_pattern: true} + pat_ast = transform(pattern, pattern_ctx) + expr_ast = transform(expr, ctx) + + {then_form, else_form} = + case body do + [t, e] -> {t, e} + [t] -> {t, nil} + end + + then_ast = transform(then_form, ctx) + else_ast = if else_form, do: transform(else_form, ctx), else: nil + + temp_var = {:clje_if_some_val, [], nil} + + ast = + {:__block__, [], + [ + {:=, [], [temp_var, expr_ast]}, + {:if, [], + [ + {:not, [], [{:is_nil, [], [temp_var]}]}, + [ + do: {:__block__, [], [{:=, [], [pat_ast, temp_var]}, then_ast]}, + else: else_ast + ] + ]} + ]} + + {ast, ctx} + end + + defp transform_when_some([{:vector, _, [pattern, expr]} | body], _meta, ctx) do + pattern_ctx = %{ctx | in_pattern: true} + pat_ast = transform(pattern, pattern_ctx) + expr_ast = transform(expr, ctx) + body_ast = transform_body(body, ctx) + + temp_var = {:clje_when_some_val, [], nil} + + ast = + {:__block__, [], + [ + {:=, [], [temp_var, expr_ast]}, + {:if, [], + [ + {:not, [], [{:is_nil, [], [temp_var]}]}, + [do: {:__block__, [], [{:=, [], [pat_ast, temp_var]}, body_ast]}] + ]} + ]} + + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # 21. use / require / import / alias + # --------------------------------------------------------------------------- + + defp transform_use(args, _meta, ctx) do + {mod_asts, opts} = parse_directive_args(args, ctx) + + ast = + case opts do + [] -> {:use, [], mod_asts} + _ -> {:use, [], mod_asts ++ [opts]} + end + + {ast, ctx} + end + + defp transform_require(args, _meta, ctx) do + {mod_asts, opts} = parse_directive_args(args, ctx) + + ast = + case opts do + [] -> {:require, [], mod_asts} + _ -> {:require, [], mod_asts ++ [opts]} + end + + {ast, ctx} + end + + defp transform_import(args, _meta, ctx) do + {mod_asts, opts} = parse_directive_args(args, ctx) + + ast = + case opts do + [] -> {:import, [], mod_asts} + _ -> {:import, [], mod_asts ++ [opts]} + end + + {ast, ctx} + end + + defp transform_alias(args, _meta, ctx) do + {mod_asts, opts} = parse_directive_args(args, ctx) + + ast = + case opts do + [] -> {:alias, [], mod_asts} + _ -> {:alias, [], mod_asts ++ [opts]} + end + + {ast, ctx} + end + + defp parse_directive_args(args, ctx) do + # First arg is a module name, rest might be options + case args do + [name_form | rest] -> + mod_ast = resolve_type_name(name_form) + opts = Enum.map(rest, fn o -> transform(o, ctx) end) + {[mod_ast], opts} + end + end + + # --------------------------------------------------------------------------- + # 22. Operators and builtins + # --------------------------------------------------------------------------- + + # Arithmetic: variadic + defp transform_arith(op, args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + + ast = + case t_args do + [single] when op == :- -> + # Unary minus + {:-, [], [0, single]} + + [single] -> + single + + _ -> + Enum.reduce(tl(t_args), hd(t_args), fn arg, acc -> + {op, [], [acc, arg]} + end) + end + + {ast, ctx} + end + + # Comparisons + defp transform_comparison(op, [a, b], ctx) do + a_ast = transform(a, ctx) + b_ast = transform(b, ctx) + {{op, [], [a_ast, b_ast]}, ctx} + end + + # Equality: = maps to CljElixir.Equality.equiv for cross-type equality + defp transform_equality([a, b], ctx) do + a_ast = transform(a, ctx) + b_ast = transform(b, ctx) + equiv_mod = {:__aliases__, [alias: false], [:CljElixir, :Equality]} + ast = {{:., [], [equiv_mod, :equiv]}, [], [a_ast, b_ast]} + {ast, ctx} + end + + # Numeric equality: == maps to == + defp transform_numeric_equality([a, b], ctx) do + a_ast = transform(a, ctx) + b_ast = transform(b, ctx) + {{:==, [], [a_ast, b_ast]}, ctx} + end + + # not=, != + defp transform_not_equal([a, b], ctx) do + a_ast = transform(a, ctx) + b_ast = transform(b, ctx) + equiv_mod = {:__aliases__, [alias: false], [:CljElixir, :Equality]} + equiv_call = {{:., [], [equiv_mod, :equiv]}, [], [a_ast, b_ast]} + {{:not, [], [equiv_call]}, ctx} + end + + # not + defp transform_not([a], ctx) do + a_ast = transform(a, ctx) + {{:not, [], [a_ast]}, ctx} + end + + # and, or (variadic) + defp transform_bool_op(op, args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + + ast = + Enum.reduce(tl(t_args), hd(t_args), fn arg, acc -> + {op, [], [acc, arg]} + end) + + {ast, ctx} + end + + # inc + defp transform_inc([a], ctx) do + a_ast = transform(a, ctx) + {{:+, [], [a_ast, 1]}, ctx} + end + + # dec + defp transform_dec([a], ctx) do + a_ast = transform(a, ctx) + {{:-, [], [a_ast, 1]}, ctx} + end + + # str — concatenate with <> using to_string + defp transform_str(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + + ast = + case t_args do + [] -> + "" + + [single] -> + {{:., [], [{:__aliases__, [alias: false], [:Kernel]}, :to_string]}, [], [single]} + + _ -> + stringified = + Enum.map(t_args, fn a -> + {{:., [], [{:__aliases__, [alias: false], [:Kernel]}, :to_string]}, [], [a]} + end) + + Enum.reduce(tl(stringified), hd(stringified), fn arg, acc -> + {:<>, [], [acc, arg]} + end) + end + + {ast, ctx} + end + + # println → IO.puts + defp transform_println(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + + # If multiple args, join with str first + ast = + case t_args do + [single] -> + {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], [single]} + + _ -> + # Concatenate all args as strings first + {str_ast, _} = transform_str(args, ctx) + {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [], [str_ast]} + end + + {ast, ctx} + end + + # nil? + defp transform_nil_check([a], ctx) do + a_ast = transform(a, ctx) + {{:is_nil, [], [a_ast]}, ctx} + end + + # throw → raise + defp transform_throw([a], ctx) do + a_ast = transform(a, ctx) + {{:raise, [], [a_ast]}, ctx} + end + + # count → ICounted.count (Phase 2 protocol) + defp transform_count([a], ctx) do + a_ast = transform(a, ctx) + ast = protocol_call([:CljElixir, :ICounted], :count, [a_ast]) + {ast, ctx} + end + + # hd + defp transform_hd([a], ctx) do + a_ast = transform(a, ctx) + {{:hd, [], [a_ast]}, ctx} + end + + # tl + defp transform_tl([a], ctx) do + a_ast = transform(a, ctx) + {{:tl, [], [a_ast]}, ctx} + end + + # cons → [h | t] + defp transform_cons([h, t], ctx) do + h_ast = transform(h, ctx) + t_ast = transform(t, ctx) + ast = [{:|, [], [h_ast, t_ast]}] + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # Positional constructor: (->Name arg1 arg2 ...) + # --------------------------------------------------------------------------- + + defp transform_positional_constructor(name, args, ctx) do + # "->Name" → strip -> + record_name = String.slice(name, 2..-1//1) + mod_alias = parse_module_name(record_name) + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + + # Call Module.new(args...) + ast = {{:., [], [mod_alias, :new]}, [], t_args} + {ast, ctx} + end + + # Map constructor: (map->Name {:field val ...}) + defp transform_map_constructor(name, args, ctx) do + record_name = String.slice(name, 5..-1//1) + mod_alias = parse_module_name(record_name) + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + + # Use Kernel.struct!/2 + ast = {{:., [], [{:__aliases__, [alias: false], [:Kernel]}, :struct!]}, [], [mod_alias | t_args]} + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # Quote (explicit form) + # --------------------------------------------------------------------------- + + defp transform_quote_form([inner], _meta, ctx) do + {quote_form(inner), ctx} + end + + defp quote_form(form) do + case form do + {:list, _, elements} -> + Enum.map(elements, "e_form/1) + + {:vector, _, elements} -> + Enum.map(elements, "e_form/1) + + {:symbol, _, name} -> + String.to_atom(name) + + {:map, _, pairs} -> + kv = + pairs + |> Enum.chunk_every(2) + |> Enum.map(fn [k, v] -> {quote_form(k), quote_form(v)} end) + + {:%{}, [], kv} + + {:tuple, _, elements} -> + {:{}, [], Enum.map(elements, "e_form/1)} + + other -> + other + end + end + + # --------------------------------------------------------------------------- + # Quasiquote + # --------------------------------------------------------------------------- + + defp transform_quasiquote(form, ctx) do + case form do + {:unquote, _, inner} -> + transform(inner, ctx) + + {:list, meta, elements} -> + transform_quasiquote_list(elements, meta, ctx) + + {:vector, _, elements} -> + Enum.map(elements, fn e -> transform_quasiquote(e, ctx) end) + + {:map, _, pairs} -> + kv = + pairs + |> Enum.chunk_every(2) + |> Enum.map(fn [k, v] -> + {transform_quasiquote(k, ctx), transform_quasiquote(v, ctx)} + end) + + {:%{}, [], kv} + + {:symbol, _, name} -> + String.to_atom(name) + + other -> + other + end + end + + defp transform_quasiquote_list(elements, _meta, ctx) do + # Handle splice-unquote within list elements + parts = + Enum.map(elements, fn + {:splice_unquote, _, inner} -> + {:splice, transform(inner, ctx)} + + elem -> + {:elem, transform_quasiquote(elem, ctx)} + end) + + # Build the list, splicing where needed + if Enum.any?(parts, fn {t, _} -> t == :splice end) do + # Need to concat + segments = + Enum.map(parts, fn + {:splice, ast} -> ast + {:elem, ast} -> [ast] + end) + + Enum.reduce(Enum.reverse(segments), [], fn + segment, [] when is_list(segment) -> segment + segment, acc when is_list(segment) -> + {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :concat]}, [], + [segment, acc]} + + segment, [] -> + segment + + segment, acc -> + {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :concat]}, [], + [segment, acc]} + end) + else + Enum.map(parts, fn {_, ast} -> ast end) + end + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + # Extract name from args (first symbol or with_meta wrapping) + defp extract_name([{:with_meta, _, {_meta_form, name_form}} | rest]) do + {name_form, rest} + end + + defp extract_name([name_form | rest]) do + {name_form, rest} + end + + # Convert a CljElixir name form to an Elixir module alias AST + defp module_name_ast({:symbol, _, name}) do + parse_module_name(name) + end + + defp module_name_ast(name) when is_binary(name) do + parse_module_name(name) + end + + defp module_name_ast(name) when is_atom(name) do + {:__aliases__, [alias: false], [name]} + end + + # Get the string name from a symbol/name form + defp symbol_name({:symbol, _, name}), do: name + defp symbol_name(name) when is_atom(name), do: Atom.to_string(name) + + # Convert symbol to atom for function names + defp symbol_to_atom({:symbol, _, name}), do: String.to_atom(munge_name(name)) + defp symbol_to_atom(name) when is_atom(name), do: name + + # Get vector elements + defp vector_elements({:vector, _, elements}), do: elements + + # Name munging: hyphens → underscores for identifiers + @doc "Convert CljElixir names (with hyphens) to Elixir names (with underscores)" + def munge_name(name) when is_binary(name) do + name + |> String.replace("-", "_") + |> String.replace("?", "_qmark") + |> String.replace("!", "_bang") + end + + defp munge_ffi_name(name) when is_binary(name) do + String.replace(name, "-", "_") + end + + # Create a function call AST node + defp call_with_args(name, args) do + {name, [], args} + end + + # Transform body: list of forms → block or single form + defp transform_body(forms, ctx) do + asts = Enum.map(forms, fn f -> transform(f, ctx) end) + + case asts do + [single] -> single + multiple -> {:__block__, [], multiple} + end + end + + # Generate a unique variable + defp unique_var(prefix, _ctx) do + # Use a unique reference to avoid collisions + {prefix, [], nil} + end + + # Bump gensym counter + defp bump_gensym(ctx) do + counter = ctx.gensym_counter + 1 + {counter, %{ctx | gensym_counter: counter}} + end + + # Strip leading dash from protocol function names + defp strip_leading_dash("-" <> rest), do: rest + defp strip_leading_dash(name), do: name + + # Resolve type names for extend-type etc + defp resolve_type_name({:symbol, _, name}), do: parse_module_name(name) + defp resolve_type_name(name) when is_binary(name), do: parse_module_name(name) + defp resolve_type_name(name) when is_atom(name), do: name + + # Parse protocol groups from extend-type/defrecord inline impls + # Input: [ProtoName, (fn_form1), (fn_form2), ProtoName2, ...] + defp parse_protocol_groups(forms) do + do_parse_protocol_groups(forms, nil, [], []) + end + + defp do_parse_protocol_groups([], nil, _fns, acc) do + Enum.reverse(acc) + end + + defp do_parse_protocol_groups([], current_proto, fns, acc) do + Enum.reverse([{current_proto, Enum.reverse(fns)} | acc]) + end + + # A symbol that starts with uppercase or looks like a protocol name + defp do_parse_protocol_groups([{:symbol, _, name} = sym | rest], current_proto, fns, acc) do + first = String.first(name) + + if first == String.upcase(first) and first != String.downcase(first) and + not String.starts_with?(name, "-") do + # This is a new protocol name + new_acc = + if current_proto do + [{current_proto, Enum.reverse(fns)} | acc] + else + acc + end + + do_parse_protocol_groups(rest, symbol_name(sym), [], new_acc) + else + # This is a function definition form (should be a list, but handle symbol gracefully) + do_parse_protocol_groups(rest, current_proto, [sym | fns], acc) + end + end + + defp do_parse_protocol_groups([form | rest], current_proto, fns, acc) do + do_parse_protocol_groups(rest, current_proto, [form | fns], acc) + end + + # Parse type groups for extend-protocol + defp parse_type_groups(forms) do + do_parse_type_groups(forms, nil, [], []) + end + + defp do_parse_type_groups([], nil, _fns, acc) do + Enum.reverse(acc) + end + + defp do_parse_type_groups([], current_type, fns, acc) do + Enum.reverse([{current_type, Enum.reverse(fns)} | acc]) + end + + defp do_parse_type_groups([{:symbol, _, _name} = sym | rest], current_type, fns, acc) do + # Check if this looks like a type name (next form is a list/function def) + # Heuristic: if the next form is a list (function definition), this is a type name + is_type = + case rest do + [{:list, _, _} | _] -> true + # Also could be the last type with no functions following + [] -> false + _ -> false + end + + if is_type or (current_type == nil) do + new_acc = + if current_type do + [{current_type, Enum.reverse(fns)} | acc] + else + acc + end + + do_parse_type_groups(rest, sym, [], new_acc) + else + do_parse_type_groups(rest, current_type, [sym | fns], acc) + end + end + + defp do_parse_type_groups([form | rest], current_type, fns, acc) do + do_parse_type_groups(rest, current_type, [form | fns], acc) + end + + # Transform protocol function definitions + defp transform_protocol_fns(fns, ctx) do + Enum.flat_map(fns, fn + {:list, _, [{:symbol, _, fname} | clause_args]} -> + fun_atom = String.to_atom(munge_name(strip_leading_dash(fname))) + transform_protocol_fn_clauses(fun_atom, clause_args, ctx) + + _ -> + [] + end) + end + + defp transform_protocol_fn_clauses(fun_atom, args, ctx) do + case args do + # Single clause: (-fname [params] body...) + [{:vector, _, params} | body] -> + pattern_ctx = %{ctx | in_pattern: true} + param_asts = Enum.map(params, fn p -> transform(p, pattern_ctx) end) + body_ast = transform_body(body, ctx) + [{:def, [], [{fun_atom, [], param_asts}, [do: body_ast]]}] + + # Multi-clause: (-fname ([p1] b1) ([p2 p3] b2)) + clauses -> + Enum.map(clauses, fn + {:list, _, [{:vector, _, params} | body]} -> + pattern_ctx = %{ctx | in_pattern: true} + param_asts = Enum.map(params, fn p -> transform(p, pattern_ctx) end) + body_ast = transform_body(body, ctx) + {:def, [], [{fun_atom, [], param_asts}, [do: body_ast]]} + end) + end + end + + # --------------------------------------------------------------------------- + # Phase 2: Protocol-backed core functions + # --------------------------------------------------------------------------- + + defp protocol_call(mod_parts, fun, args) do + mod_ast = {:__aliases__, [alias: false], mod_parts} + {{:., [], [mod_ast, fun]}, [], args} + end + + # get + defp transform_get([m, k], ctx) do + m_ast = transform(m, ctx) + k_ast = transform(k, ctx) + ast = protocol_call([:CljElixir, :ILookup], :lookup, [m_ast, k_ast]) + {ast, ctx} + end + + defp transform_get([m, k, nf], ctx) do + m_ast = transform(m, ctx) + k_ast = transform(k, ctx) + nf_ast = transform(nf, ctx) + ast = protocol_call([:CljElixir, :ILookup], :lookup, [m_ast, k_ast, nf_ast]) + {ast, ctx} + end + + # assoc + defp transform_assoc([m, k, v], ctx) do + m_ast = transform(m, ctx) + k_ast = transform(k, ctx) + v_ast = transform(v, ctx) + ast = protocol_call([:CljElixir, :IAssociative], :assoc, [m_ast, k_ast, v_ast]) + {ast, ctx} + end + + # dissoc + defp transform_dissoc([m, k], ctx) do + m_ast = transform(m, ctx) + k_ast = transform(k, ctx) + ast = protocol_call([:CljElixir, :IMap], :dissoc, [m_ast, k_ast]) + {ast, ctx} + end + + # update - (update m k f) => assoc(m, k, f.(lookup(m, k))) + defp transform_update([m, k, f], ctx) do + m_ast = transform(m, ctx) + k_ast = transform(k, ctx) + f_ast = transform(f, ctx) + lookup_ast = protocol_call([:CljElixir, :ILookup], :lookup, [m_ast, k_ast]) + apply_ast = {{:., [], [f_ast]}, [], [lookup_ast]} + ast = protocol_call([:CljElixir, :IAssociative], :assoc, [m_ast, k_ast, apply_ast]) + {ast, ctx} + end + + # conj + defp transform_conj([c, x], ctx) do + c_ast = transform(c, ctx) + x_ast = transform(x, ctx) + ast = protocol_call([:CljElixir, :ICollection], :conj, [c_ast, x_ast]) + {ast, ctx} + end + + # contains? + defp transform_contains([m, k], ctx) do + m_ast = transform(m, ctx) + k_ast = transform(k, ctx) + ast = protocol_call([:CljElixir, :IAssociative], :contains_key_qmark, [m_ast, k_ast]) + {ast, ctx} + end + + # empty? + defp transform_empty([c], ctx) do + c_ast = transform(c, ctx) + count_ast = protocol_call([:CljElixir, :ICounted], :count, [c_ast]) + ast = {:==, [context: Elixir, imports: [{2, Kernel}]], [count_ast, 0]} + {ast, ctx} + end + + # nth + defp transform_nth([c, n], ctx) do + c_ast = transform(c, ctx) + n_ast = transform(n, ctx) + ast = protocol_call([:CljElixir, :IIndexed], :nth, [c_ast, n_ast]) + {ast, ctx} + end + + defp transform_nth([c, n, nf], ctx) do + c_ast = transform(c, ctx) + n_ast = transform(n, ctx) + nf_ast = transform(nf, ctx) + ast = protocol_call([:CljElixir, :IIndexed], :nth, [c_ast, n_ast, nf_ast]) + {ast, ctx} + end + + # first + defp transform_first([c], ctx) do + c_ast = transform(c, ctx) + ast = protocol_call([:CljElixir, :ISeq], :first, [c_ast]) + {ast, ctx} + end + + # rest + defp transform_rest([c], ctx) do + c_ast = transform(c, ctx) + ast = protocol_call([:CljElixir, :ISeq], :rest, [c_ast]) + {ast, ctx} + end + + # seq + defp transform_seq([c], ctx) do + c_ast = transform(c, ctx) + ast = protocol_call([:CljElixir, :ISeqable], :seq, [c_ast]) + {ast, ctx} + end + + # reduce - 2 or 3 arg form + defp transform_reduce([f, coll], ctx) do + f_ast = transform(f, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :reduce]}, [], [coll_ast, f_ast]} + {ast, ctx} + end + + defp transform_reduce([f, init, coll], ctx) do + f_ast = transform(f, ctx) + init_ast = transform(init, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :reduce]}, [], [coll_ast, init_ast, f_ast]} + {ast, ctx} + end + + # reduce-kv + defp transform_reduce_kv([f, init, coll], ctx) do + f_ast = transform(f, ctx) + init_ast = transform(init, ctx) + coll_ast = transform(coll, ctx) + ast = protocol_call([:CljElixir, :IKVReduce], :kv_reduce, [coll_ast, f_ast, init_ast]) + {ast, ctx} + end + + # map (as function, not data literal) + defp transform_map_fn([f, coll], ctx) do + f_ast = transform(f, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :map]}, [], [coll_ast, f_ast]} + {ast, ctx} + end + + # filter + defp transform_filter([pred, coll], ctx) do + pred_ast = transform(pred, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :filter]}, [], [coll_ast, pred_ast]} + {ast, ctx} + end + + # concat + defp transform_concat([a, b], ctx) do + a_ast = transform(a, ctx) + b_ast = transform(b, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :concat]}, [], [a_ast, b_ast]} + {ast, ctx} + end + + # take + defp transform_take([n, coll], ctx) do + n_ast = transform(n, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :take]}, [], [coll_ast, n_ast]} + {ast, ctx} + end + + # drop + defp transform_drop([n, coll], ctx) do + n_ast = transform(n, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :drop]}, [], [coll_ast, n_ast]} + {ast, ctx} + end + + # sort + defp transform_sort([coll], ctx) do + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :sort]}, [], [coll_ast]} + {ast, ctx} + end + + # sort-by + defp transform_sort_by([f, coll], ctx) do + f_ast = transform(f, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :sort_by]}, [], [coll_ast, f_ast]} + {ast, ctx} + end + + # group-by + defp transform_group_by([f, coll], ctx) do + f_ast = transform(f, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :group_by]}, [], [coll_ast, f_ast]} + {ast, ctx} + end + + # frequencies + defp transform_frequencies([coll], ctx) do + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :frequencies]}, [], [coll_ast]} + {ast, ctx} + end + + # distinct + defp transform_distinct([coll], ctx) do + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :uniq]}, [], [coll_ast]} + {ast, ctx} + end + + # mapcat + defp transform_mapcat([f, coll], ctx) do + f_ast = transform(f, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :flat_map]}, [], [coll_ast, f_ast]} + {ast, ctx} + end + + # partition + defp transform_partition([n, coll], ctx) do + n_ast = transform(n, ctx) + coll_ast = transform(coll, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :chunk_every]}, [], [coll_ast, n_ast]} + {ast, ctx} + end + + # keys + defp transform_keys([m], ctx) do + m_ast = transform(m, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Map]}, :keys]}, [], [m_ast]} + {ast, ctx} + end + + # vals + defp transform_vals([m], ctx) do + m_ast = transform(m, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Map]}, :values]}, [], [m_ast]} + {ast, ctx} + end + + # merge + defp transform_merge([m1, m2], ctx) do + m1_ast = transform(m1, ctx) + m2_ast = transform(m2, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Map]}, :merge]}, [], [m1_ast, m2_ast]} + {ast, ctx} + end + + # select-keys + defp transform_select_keys([m, ks], ctx) do + m_ast = transform(m, ctx) + ks_ast = transform(ks, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Map]}, :take]}, [], [m_ast, ks_ast]} + {ast, ctx} + end + + # into + defp transform_into([c1, c2], ctx) do + c1_ast = transform(c1, ctx) + c2_ast = transform(c2, ctx) + ast = {{:., [], [{:__aliases__, [alias: false], [:Enum]}, :into]}, [], [c2_ast, c1_ast]} + {ast, ctx} + end + + # get-in + defp transform_get_in([m, ks], ctx) do + m_ast = transform(m, ctx) + ks_ast = transform(ks, ctx) + ast = protocol_call([:CljElixir, :Core], :get_in, [m_ast, ks_ast]) + {ast, ctx} + end + + # assoc-in + defp transform_assoc_in([m, ks, v], ctx) do + m_ast = transform(m, ctx) + ks_ast = transform(ks, ctx) + v_ast = transform(v, ctx) + ast = protocol_call([:CljElixir, :Core], :assoc_in, [m_ast, ks_ast, v_ast]) + {ast, ctx} + end + + # update-in + defp transform_update_in([m, ks, f], ctx) do + m_ast = transform(m, ctx) + ks_ast = transform(ks, ctx) + f_ast = transform(f, ctx) + ast = protocol_call([:CljElixir, :Core], :update_in, [m_ast, ks_ast, f_ast]) + {ast, ctx} + end + + # list - creates an Elixir list from arguments + defp transform_list_call(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + {t_args, ctx} + end + + # --------------------------------------------------------------------------- + # Phase 3: Vector builtins + # --------------------------------------------------------------------------- + + # (vec coll) — convert any collection to PersistentVector + defp transform_vec([coll], ctx) do + coll_ast = transform(coll, ctx) + pv_mod = {:__aliases__, [alias: false], [:CljElixir, :PersistentVector]} + enum_mod = {:__aliases__, [alias: false], [:Enum]} + to_list = {{:., [], [enum_mod, :to_list]}, [], [coll_ast]} + ast = {{:., [], [pv_mod, :from_list]}, [], [to_list]} + {ast, ctx} + end + + # (vector & args) — create PersistentVector from arguments + defp transform_vector_call(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + pv_mod = {:__aliases__, [alias: false], [:CljElixir, :PersistentVector]} + ast = {{:., [], [pv_mod, :from_list]}, [], [t_args]} + {ast, ctx} + end + + # (subvec v start) or (subvec v start end) + defp transform_subvec_call([v, start], ctx) do + v_ast = transform(v, ctx) + start_ast = transform(start, ctx) + sv_mod = {:__aliases__, [alias: false], [:CljElixir, :SubVector]} + ast = {{:., [], [sv_mod, :sv_new]}, [], [v_ast, start_ast]} + {ast, ctx} + end + + defp transform_subvec_call([v, start, end_], ctx) do + v_ast = transform(v, ctx) + start_ast = transform(start, ctx) + end_ast = transform(end_, ctx) + sv_mod = {:__aliases__, [alias: false], [:CljElixir, :SubVector]} + ast = {{:., [], [sv_mod, :sv_new]}, [], [v_ast, start_ast, end_ast]} + {ast, ctx} + end + + # (peek coll) — protocol dispatch to IStack + defp transform_peek([coll], ctx) do + coll_ast = transform(coll, ctx) + ast = protocol_call([:CljElixir, :IStack], :peek, [coll_ast]) + {ast, ctx} + end + + # (pop coll) — protocol dispatch to IStack + defp transform_pop([coll], ctx) do + coll_ast = transform(coll, ctx) + ast = protocol_call([:CljElixir, :IStack], :pop, [coll_ast]) + {ast, ctx} + end + + # (vector? x) — check if x is a PersistentVector + defp transform_vector_check([x], ctx) do + x_ast = transform(x, ctx) + pv_mod = {:__aliases__, [alias: false], [:CljElixir, :PersistentVector]} + ast = {{:., [], [{:__aliases__, [alias: false], [:Kernel]}, :is_struct]}, [], [x_ast, pv_mod]} + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # Phase 4: Domain tools + # --------------------------------------------------------------------------- + + # (tuple & args) — create a BEAM tuple from arguments + defp transform_tuple_fn(args, ctx) do + t_args = Enum.map(args, fn a -> transform(a, ctx) end) + ast = {:{}, [], t_args} + {ast, ctx} + end + + # (clojurify x) — protocol dispatch to IClojurify + defp transform_clojurify([x], ctx) do + x_ast = transform(x, ctx) + ast = protocol_call([:CljElixir, :IClojurify], :clojurify, [x_ast]) + {ast, ctx} + end + + # (elixirify x) — protocol dispatch to IElixirify + defp transform_elixirify([x], ctx) do + x_ast = transform(x, ctx) + ast = protocol_call([:CljElixir, :IElixirify], :elixirify, [x_ast]) + {ast, ctx} + end + + # --------------------------------------------------------------------------- + # Phase 6: Threading macros (-> and ->>) + # --------------------------------------------------------------------------- + + # (-> x) → x + # (-> x form1 form2 ...) → thread x through forms, inserting as first arg + defp transform_thread_first([], _meta, _ctx) do + raise "-> requires at least one argument" + end + + defp transform_thread_first([x], _meta, ctx) do + do_transform(x, ctx) + end + + defp transform_thread_first([x | forms], meta, ctx) do + threaded = Enum.reduce(forms, x, fn form, acc -> + thread_form(acc, form, meta, :first) + end) + + do_transform(threaded, ctx) + end + + # (->> x) → x + # (->> x form1 form2 ...) → thread x through forms, inserting as last arg + defp transform_thread_last([], _meta, _ctx) do + raise "->> requires at least one argument" + end + + defp transform_thread_last([x], _meta, ctx) do + do_transform(x, ctx) + end + + defp transform_thread_last([x | forms], meta, ctx) do + threaded = Enum.reduce(forms, x, fn form, acc -> + thread_form(acc, form, meta, :last) + end) + + do_transform(threaded, ctx) + end + + # Rewrite a single threading step at the CljElixir AST level. + # - Bare symbol: wrap as (sym prev) or (sym prev) + # - Keyword (atom): wrap as (kw prev) + # - List (head ...args): insert prev as first or last arg + defp thread_form(prev, {:symbol, s_meta, _name} = sym, _meta, _position) do + {:list, s_meta, [sym, prev]} + end + + defp thread_form(prev, {:list, l_meta, [head | args]}, _meta, :first) do + {:list, l_meta, [head, prev | args]} + end + + defp thread_form(prev, {:list, l_meta, [head | args]}, _meta, :last) do + {:list, l_meta, [head | args ++ [prev]]} + end + + defp thread_form(prev, kw, _meta, _position) when is_atom(kw) do + {:list, %{}, [kw, prev]} + end + + defp thread_form(prev, other, _meta, _position) do + # For any other form (vector, map, etc.), just use as-is — likely an error + # but let the transformer handle it + {:list, %{}, [other, prev]} + end + + # --------------------------------------------------------------------------- + # try / catch / finally + # --------------------------------------------------------------------------- + + # (try body... (catch ...) ... (finally ...)) + defp transform_try(args, _meta, ctx) do + {body_forms, catch_clauses, finally_clause} = partition_try_args(args) + + # Transform body + body_ast = transform_body(body_forms, ctx) + + # Build the try keyword list + try_opts = [do: body_ast] + + # Process catch clauses into rescue and catch groups + {rescue_clauses, catch_clauses_ast} = classify_catch_clauses(catch_clauses, ctx) + + try_opts = + if rescue_clauses != [] do + try_opts ++ [rescue: rescue_clauses] + else + try_opts + end + + try_opts = + if catch_clauses_ast != [] do + try_opts ++ [catch: catch_clauses_ast] + else + try_opts + end + + try_opts = + case finally_clause do + nil -> + try_opts + + forms -> + after_ast = transform_body(forms, ctx) + try_opts ++ [after: after_ast] + end + + ast = {:try, [], [try_opts]} + {ast, ctx} + end + + # Separate try args into body forms, catch clauses, and an optional finally clause. + defp partition_try_args(args) do + {body, catches, finally} = + Enum.reduce(args, {[], [], nil}, fn form, {body_acc, catch_acc, finally_acc} -> + case form do + {:list, _, [{:symbol, _, "catch"} | catch_args]} -> + {body_acc, catch_acc ++ [catch_args], finally_acc} + + {:list, _, [{:symbol, _, "finally"} | finally_args]} -> + {body_acc, catch_acc, finally_args} + + _ -> + {body_acc ++ [form], catch_acc, finally_acc} + end + end) + + {body, catches, finally} + end + + # Classify each catch clause as either a rescue or catch clause. + # Returns {rescue_clauses_ast, catch_clauses_ast} + defp classify_catch_clauses(catch_clauses, ctx) do + Enum.reduce(catch_clauses, {[], []}, fn clause, {rescue_acc, catch_acc} -> + case clause do + # (catch :error e body...) / (catch :exit e body...) / (catch :throw e body...) + [kind, {:symbol, _, binding_name} | body] + when kind in [:error, :exit, :throw] -> + binding_var = {String.to_atom(munge_name(binding_name)), [], nil} + body_ast = transform_body(body, ctx) + clause_ast = {:->, [], [[kind, binding_var], body_ast]} + {rescue_acc, catch_acc ++ [clause_ast]} + + # (catch ExType e body...) — uppercase symbol = rescue with type + [{:symbol, _, type_name}, {:symbol, _, binding_name} | body] -> + if uppercase_start?(type_name) do + type_ast = parse_module_name(type_name) + binding_var = {String.to_atom(munge_name(binding_name)), [], nil} + body_ast = transform_body(body, ctx) + rescue_pattern = {:in, [], [binding_var, [type_ast]]} + clause_ast = {:->, [], [[rescue_pattern], body_ast]} + {rescue_acc ++ [clause_ast], catch_acc} + else + # lowercase symbol as first arg, no type — rescue any + # Treat the first symbol as the binding, rest as body + binding_var = {String.to_atom(munge_name(type_name)), [], nil} + rest_body = [{:symbol, %{}, binding_name} | body] + body_ast = transform_body(rest_body, ctx) + clause_ast = {:->, [], [[binding_var], body_ast]} + {rescue_acc ++ [clause_ast], catch_acc} + end + + # (catch e body...) — single lowercase symbol = rescue any exception + [{:symbol, _, binding_name} | body] -> + binding_var = {String.to_atom(munge_name(binding_name)), [], nil} + body_ast = transform_body(body, ctx) + clause_ast = {:->, [], [[binding_var], body_ast]} + {rescue_acc ++ [clause_ast], catch_acc} + + _ -> + raise "Invalid catch clause: #{inspect(clause)}" + end + end) + end + + defp uppercase_start?(name) do + first = String.first(name) + first == String.upcase(first) and first != String.downcase(first) + end + + # --------------------------------------------------------------------------- + # Phase 6: defmacro + # --------------------------------------------------------------------------- + + defp transform_defmacro(args, _meta, ctx) do + {name_form, rest} = extract_name(args) + macro_name = symbol_name(name_form) + + # Parse params and body (same structure as defn, single-arity only) + [{required_params, rest_param, _guard, body_forms}] = parse_defn_clauses(rest) + + required_count = length(required_params) + has_rest = rest_param != nil + + # Build parameter variable ASTs for the anonymous function + param_vars = + Enum.map(required_params, fn + {:symbol, _, pname} -> {String.to_atom(munge_name(pname)), [], nil} + end) + + rest_var = + case rest_param do + {:symbol, _, rname} -> {String.to_atom(munge_name(rname)), [], nil} + nil -> nil + end + + all_param_vars = if rest_var, do: param_vars ++ [rest_var], else: param_vars + + # Transform the macro body with in_macro: true + # This causes quasiquote to produce CljElixir AST constructors + macro_ctx = %{ctx | in_macro: true, in_pattern: false} + body_ast = transform_body(body_forms, macro_ctx) + + # Build the fn AST + fn_clause = {:->, [], [all_param_vars, body_ast]} + fn_ast = {:fn, [], [fn_clause]} + + # Evaluate the function to get a runtime callable + {fun, _bindings} = Code.eval_quoted(fn_ast) + + # Store in context + new_macros = Map.put(ctx.macros, macro_name, {fun, required_count, has_rest}) + new_ctx = %{ctx | macros: new_macros} + + # defmacro produces no runtime code + {nil, new_ctx} + end + + # --------------------------------------------------------------------------- + # Macro expansion + # --------------------------------------------------------------------------- + + defp expand_macro(name, args, ctx) do + {fun, required_count, has_rest} = ctx.macros[name] + + # Call the macro function with CljElixir AST args + expanded = + if has_rest do + {required_args, rest_args} = Enum.split(args, required_count) + apply(fun, required_args ++ [rest_args]) + else + apply(fun, args) + end + + # The macro returns CljElixir AST — transform it normally + do_transform(expanded, ctx) + end + + # --------------------------------------------------------------------------- + # Macro quasiquote: produces code that constructs CljElixir AST + # --------------------------------------------------------------------------- + + # Override quasiquote behavior when in_macro is true + # This is handled in do_transform for {:quasiquote, ...} + + defp transform_macro_quasiquote(form, ctx, gensym_map) do + m = {:%{}, [], [{:line, 0}, {:col, 0}]} + + case form do + {:unquote, _, inner} -> + # Evaluate inner normally — produces a CljElixir AST value at expansion time + transform(inner, %{ctx | in_macro: false}) + + {:symbol, _, name} -> + if String.ends_with?(name, "#") do + # Auto-gensym: use the pre-generated variable + base = String.trim_trailing(name, "#") + var_name = Map.get(gensym_map, base) + # Reference the gensym variable + {var_name, [], nil} + else + # Literal symbol: construct {:symbol, %{line: 0, col: 0}, name} + {:{}, [], [:symbol, m, name]} + end + + {:list, _, elements} -> + transform_macro_quasiquote_list(elements, ctx, gensym_map, m) + + {:vector, _, elements} -> + elems = Enum.map(elements, &transform_macro_quasiquote(&1, ctx, gensym_map)) + {:{}, [], [:vector, m, elems]} + + {:map, _, pairs} -> + pairs_asts = Enum.map(pairs, &transform_macro_quasiquote(&1, ctx, gensym_map)) + {:{}, [], [:map, m, pairs_asts]} + + {:tuple, _, elements} -> + elems = Enum.map(elements, &transform_macro_quasiquote(&1, ctx, gensym_map)) + {:{}, [], [:tuple, m, elems]} + + {:set, _, elements} -> + elems = Enum.map(elements, &transform_macro_quasiquote(&1, ctx, gensym_map)) + {:{}, [], [:set, m, elems]} + + # Literals pass through as-is (they're valid CljElixir AST) + n when is_integer(n) -> n + n when is_float(n) -> n + s when is_binary(s) -> s + a when is_atom(a) -> a + true -> true + false -> false + nil -> nil + end + end + + defp transform_macro_quasiquote_list(elements, ctx, gensym_map, m) do + has_splice = + Enum.any?(elements, fn + {:splice_unquote, _, _} -> true + _ -> false + end) + + if has_splice do + # Build segments: fixed elements wrapped in lists, splices as-is + segments = + Enum.map(elements, fn + {:splice_unquote, _, inner} -> + # Splice: evaluates to a list of CljElixir AST nodes + {:splice, transform(inner, %{ctx | in_macro: false})} + + elem -> + {:single, transform_macro_quasiquote(elem, ctx, gensym_map)} + end) + + # Group consecutive singles, keep splices separate + chunks = chunk_splice_segments(segments) + + # Build concatenation expression + concat_expr = build_splice_concat(chunks) + + {:{}, [], [:list, m, concat_expr]} + else + elems = Enum.map(elements, &transform_macro_quasiquote(&1, ctx, gensym_map)) + {:{}, [], [:list, m, elems]} + end + end + + defp chunk_splice_segments(segments) do + # Group consecutive :single items into lists, keep :splice items separate + Enum.chunk_by(segments, fn {type, _} -> type end) + |> Enum.flat_map(fn + [{:splice, _} | _] = splices -> + # Each splice is its own chunk + Enum.map(splices, fn {:splice, ast} -> {:splice, ast} end) + + singles -> + [{:list, Enum.map(singles, fn {:single, ast} -> ast end)}] + end) + end + + defp build_splice_concat(chunks) do + case chunks do + [{:list, elems}] -> + elems + + _ -> + # Use Enum.concat with list of segments + enum_mod = {:__aliases__, [alias: false], [:Enum]} + + segments = + Enum.map(chunks, fn + {:splice, ast} -> ast + {:list, elems} -> elems + end) + + {{:., [], [enum_mod, :concat]}, [], [segments]} + end + end + + # --------------------------------------------------------------------------- + # Auto-gensym support + # --------------------------------------------------------------------------- + + defp collect_gensyms(form) do + # Walk the form and find all symbols ending in # + names = do_collect_gensyms(form, MapSet.new()) + + # Generate unique variable atoms for each + gensym_map = + Enum.reduce(names, %{}, fn base, acc -> + var_atom = String.to_atom("gensym_#{base}") + Map.put(acc, base, var_atom) + end) + + {MapSet.to_list(names), gensym_map} + end + + defp do_collect_gensyms(form, acc) do + case form do + {:symbol, _, name} -> + if String.ends_with?(name, "#") do + MapSet.put(acc, String.trim_trailing(name, "#")) + else + acc + end + + {:list, _, elements} -> + Enum.reduce(elements, acc, &do_collect_gensyms/2) + + {:vector, _, elements} -> + Enum.reduce(elements, acc, &do_collect_gensyms/2) + + {:map, _, pairs} -> + Enum.reduce(pairs, acc, &do_collect_gensyms/2) + + {:tuple, _, elements} -> + Enum.reduce(elements, acc, &do_collect_gensyms/2) + + {:set, _, elements} -> + Enum.reduce(elements, acc, &do_collect_gensyms/2) + + # Don't look inside unquotes — they're evaluated, not quoted + {:unquote, _, _} -> acc + {:splice_unquote, _, _} -> acc + _ -> acc + end + end + + defp generate_gensym_bindings(gensym_names) do + # For each gensym base name, generate a binding: + # __gensym_foo__ = {:symbol, %{line: 0, col: 0}, "foo__" <> Integer.to_string(:erlang.unique_integer([:positive]))} + m = {:%{}, [], [{:line, 0}, {:col, 0}]} + + Enum.map(gensym_names, fn base -> + var_atom = String.to_atom("gensym_#{base}") + var_ref = {var_atom, [], nil} + + # Generate: {:symbol, %{line: 0, col: 0}, base <> "__" <> Integer.to_string(:erlang.unique_integer([:positive]))} + unique_call = + {{:., [], [:erlang, :unique_integer]}, [], [[:positive]]} + + to_string_call = + {{:., [], [{:__aliases__, [alias: false], [:Integer]}, :to_string]}, [], [unique_call]} + + name_expr = {:<>, [], [base <> "__", to_string_call]} + symbol_tuple = {:{}, [], [:symbol, m, name_expr]} + + {:=, [], [var_ref, symbol_tuple]} + end) + end + + # --------------------------------------------------------------------------- + # Phase 7: Malli schema integration + # --------------------------------------------------------------------------- + + # Transform (m/=> fname schema) into @spec AST + defp transform_schema_spec(args, _meta, ctx) do + [fn_name_form, schema_form] = args + + # Get the function name as an atom (with name munging) + fun_name = symbol_to_atom(fn_name_form) + + # Convert CljElixir AST schema to plain Elixir data + schema_data = clje_ast_to_data(schema_form) + + # Convert schema references using known schemas + opts = [known_types: ctx.schemas] + + # Generate @spec AST nodes + spec_asts = CljElixir.Malli.spec_ast(fun_name, schema_data, opts) + + # Return as a block if multiple specs + ast = case spec_asts do + [single] -> single + multiple -> {:__block__, [], multiple} + end + + {ast, ctx} + end + + # Convert CljElixir AST nodes to plain Elixir data for Malli processing + defp clje_ast_to_data(form) do + case form do + {:vector, _, elements} -> + Enum.map(elements, &clje_ast_to_data/1) + + {:map, _, pairs} -> + pairs + |> Enum.chunk_every(2) + |> Enum.into(%{}, fn [k, v] -> {clje_ast_to_data(k), clje_ast_to_data(v)} end) + + {:symbol, _, name} -> + # Symbol reference - could be a schema name like "User" + # Return as string for schema_to_typespec to resolve via known_types + name + + {:list, _, elements} -> + Enum.map(elements, &clje_ast_to_data/1) + + {:set, _, elements} -> + MapSet.new(Enum.map(elements, &clje_ast_to_data/1)) + + {:tuple, _, elements} -> + List.to_tuple(Enum.map(elements, &clje_ast_to_data/1)) + + # Atoms (keywords), numbers, strings, booleans, nil pass through + other -> other + end + end + + # Detect if a CljElixir AST form looks like a Malli schema definition + @schema_heads [:map, :"map-of", :and, :or, :maybe, :enum, :tuple, :list, + :vector, :set, :schema, :"=>", :=, :function] + + defp detect_schema({:vector, _, [first | _]} = form) when is_atom(first) do + if first in @schema_heads do + clje_ast_to_data(form) + else + nil + end + end + + defp detect_schema(_), do: nil + + # Convert a schema name (CamelCase or kebab-case) to a type atom + # "User" -> :user, "PositiveInt" -> :positive_int, "my-type" -> :my_type + defp schema_name_to_type_atom(name) do + name + |> String.replace(~r/([a-z])([A-Z])/, "\\1_\\2") + |> String.downcase() + |> String.replace("-", "_") + |> String.to_atom() + end +end diff --git a/lib/mix/tasks/compile.clj_elixir.ex b/lib/mix/tasks/compile.clj_elixir.ex new file mode 100644 index 0000000..1aa4893 --- /dev/null +++ b/lib/mix/tasks/compile.clj_elixir.ex @@ -0,0 +1,336 @@ +defmodule Mix.Tasks.Compile.CljElixir do + @moduledoc """ + Mix compiler plugin for CljElixir `.clje` files. + + Integrates `.clje` source files into the standard Mix build pipeline. + Supports incremental compilation via a manifest that tracks source file + modification times and the modules they produce. + + ## Configuration + + In your `mix.exs`, add `:compile.clj_elixir` to your compilers and + configure source paths: + + def project do + [ + compilers: [:clj_elixir] ++ Mix.compilers(), + clj_elixir_paths: ["src"] + ] + end + + ## How It Works + + 1. Scans configured source paths for `.clje` files + 2. Checks the manifest for previously compiled files and their mtimes + 3. Compiles only stale files (new or modified since last build) + 4. Writes `.beam` files to the build output directory + 5. Updates the manifest with new module info + 6. Returns `{:ok, diagnostics}` or `{:error, diagnostics}` + + ## Manifest + + The manifest is stored at `_build/ENV/.clj_elixir_manifest` and tracks + `{source_path, mtime, [module_names]}` tuples for incremental compilation. + """ + + use Mix.Task.Compiler + + @manifest_filename ".clj_elixir_manifest" + @recursive true + + @impl true + def run(argv) do + {opts, _, _} = + OptionParser.parse(argv, + switches: [force: :boolean, verbose: :boolean], + aliases: [f: :force, v: :verbose] + ) + + force? = opts[:force] || false + verbose? = opts[:verbose] || false + + project = Mix.Project.config() + source_paths = project[:clj_elixir_paths] || ["src"] + build_path = Mix.Project.compile_path(project) + + # Ensure build directory exists + File.mkdir_p!(build_path) + + # Find all .clje source files + sources = find_sources(source_paths) + + if sources == [] do + {:noop, []} + else + manifest_path = manifest_path() + manifest = load_manifest(manifest_path) + + # Determine which files need recompilation + {stale, removed} = partition_sources(sources, manifest, force?) + + if stale == [] and removed == [] do + if verbose?, do: Mix.shell().info("All .clje files are up to date") + {:noop, []} + else + # Clean up modules from removed source files + removed_diagnostics = clean_removed(removed, manifest, build_path, verbose?) + + # Compile stale files + {compiled, diagnostics} = compile_stale(stale, build_path, verbose?) + + # Build new manifest from existing (unchanged) + newly compiled + unchanged_entries = + manifest + |> Enum.reject(fn {path, _mtime, _modules} -> + path in stale or path in removed + end) + + new_manifest = unchanged_entries ++ compiled + save_manifest(manifest_path, new_manifest) + + all_diagnostics = removed_diagnostics ++ diagnostics + + has_errors? = Enum.any?(all_diagnostics, &(&1.severity == :error)) + + if has_errors? do + {:error, to_mix_diagnostics(all_diagnostics)} + else + {:ok, to_mix_diagnostics(all_diagnostics)} + end + end + end + end + + @impl true + def manifests do + [manifest_path()] + end + + @impl true + def clean do + manifest_path = manifest_path() + manifest = load_manifest(manifest_path) + build_path = Mix.Project.compile_path() + + Enum.each(manifest, fn {_path, _mtime, modules} -> + Enum.each(modules, fn module -> + beam_file = Path.join(build_path, "#{module}.beam") + File.rm(beam_file) + end) + end) + + File.rm(manifest_path) + :ok + end + + # --------------------------------------------------------------------------- + # Source discovery + # --------------------------------------------------------------------------- + + defp find_sources(paths) do + paths + |> Enum.flat_map(fn path -> + path + |> Path.join("**/*.clje") + |> Path.wildcard() + end) + |> Enum.sort_by(fn path -> + parts = Path.split(path) + basename = Path.basename(path, ".clje") + # Protocols must compile first (priority 0), then everything else (priority 1) + priority = if basename == "protocols", do: 0, else: 1 + {-length(parts), priority, path} + end) + end + + # --------------------------------------------------------------------------- + # Staleness detection + # --------------------------------------------------------------------------- + + defp partition_sources(sources, manifest, force?) do + manifest_map = Map.new(manifest, fn {path, mtime, modules} -> {path, {mtime, modules}} end) + + stale = + if force? do + sources + else + Enum.filter(sources, fn source -> + case Map.get(manifest_map, source) do + nil -> + true + + {old_mtime, _modules} -> + current_mtime = file_mtime(source) + current_mtime > old_mtime + end + end) + end + + source_set = MapSet.new(sources) + + removed = + manifest + |> Enum.map(fn {path, _mtime, _modules} -> path end) + |> Enum.reject(&MapSet.member?(source_set, &1)) + + {stale, removed} + end + + # --------------------------------------------------------------------------- + # Compilation + # --------------------------------------------------------------------------- + + defp compile_stale(sources, build_path, verbose?) do + results = + Enum.map(sources, fn source -> + if verbose?, do: Mix.shell().info("Compiling #{source}") + compile_source(source, build_path) + end) + + {compiled, diagnostics} = + Enum.reduce(results, {[], []}, fn + {:ok, entry, diags}, {compiled, all_diags} -> + {[entry | compiled], all_diags ++ diags} + + {:error, diags}, {compiled, all_diags} -> + {compiled, all_diags ++ diags} + end) + + {Enum.reverse(compiled), diagnostics} + end + + defp compile_source(source, build_path) do + case CljElixir.Compiler.compile_file_to_beam(source, + output_dir: build_path, + vector_as_list: true + ) do + {:ok, modules} -> + mtime = file_mtime(source) + module_names = Enum.map(modules, fn {mod, _binary} -> mod end) + entry = {source, mtime, module_names} + + diagnostics = + if module_names == [] do + [ + %{ + severity: :warning, + message: "#{source} produced no modules", + file: source, + line: 0, + col: 0 + } + ] + else + [] + end + + {:ok, entry, diagnostics} + + {:error, diagnostics} -> + enriched = + Enum.map(diagnostics, fn diag -> + Map.put_new(diag, :file, source) + end) + + {:error, enriched} + end + end + + # --------------------------------------------------------------------------- + # Cleanup + # --------------------------------------------------------------------------- + + defp clean_removed(removed, manifest, build_path, verbose?) do + manifest_map = Map.new(manifest, fn {path, mtime, modules} -> {path, {mtime, modules}} end) + + Enum.flat_map(removed, fn path -> + case Map.get(manifest_map, path) do + nil -> + [] + + {_mtime, modules} -> + if verbose?, do: Mix.shell().info("Cleaning removed source #{path}") + + Enum.each(modules, fn module -> + beam_file = Path.join(build_path, "#{module}.beam") + File.rm(beam_file) + + # Purge the module from the code server + :code.purge(module) + :code.delete(module) + end) + + [] + end + end) + end + + # --------------------------------------------------------------------------- + # Manifest I/O + # --------------------------------------------------------------------------- + + defp manifest_path do + Path.join(Mix.Project.manifest_path(), @manifest_filename) + end + + defp load_manifest(path) do + case File.read(path) do + {:ok, contents} -> + try do + contents + |> :erlang.binary_to_term() + |> validate_manifest() + rescue + _ -> [] + end + + {:error, _} -> + [] + end + end + + defp validate_manifest(data) when is_list(data) do + Enum.filter(data, fn + {path, mtime, modules} + when is_binary(path) and is_integer(mtime) and is_list(modules) -> + true + + _ -> + false + end) + end + + defp validate_manifest(_), do: [] + + defp save_manifest(path, entries) do + File.mkdir_p!(Path.dirname(path)) + File.write!(path, :erlang.term_to_binary(entries)) + end + + # --------------------------------------------------------------------------- + # Diagnostics + # --------------------------------------------------------------------------- + + defp to_mix_diagnostics(diagnostics) do + Enum.map(diagnostics, fn diag -> + %Mix.Task.Compiler.Diagnostic{ + file: Map.get(diag, :file, "unknown"), + severity: diag.severity, + message: diag.message, + position: diag.line, + compiler_name: "clj_elixir" + } + end) + end + + # --------------------------------------------------------------------------- + # File utilities + # --------------------------------------------------------------------------- + + defp file_mtime(path) do + case File.stat(path, time: :posix) do + {:ok, %{mtime: mtime}} -> mtime + {:error, _} -> 0 + end + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..995fdb6 --- /dev/null +++ b/mix.exs @@ -0,0 +1,27 @@ +defmodule CljElixir.MixProject do + use Mix.Project + + def project do + [ + app: :clj_elixir, + version: "0.1.0", + elixir: "~> 1.16", + start_permanent: Mix.env() == :prod, + compilers: Mix.compilers() ++ [:clj_elixir], + deps: deps(), + elixirc_paths: ["lib"], + test_paths: ["test"], + clj_elixir_paths: ["src"] + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [] + end +end diff --git a/src/clje/core.clje b/src/clje/core.clje new file mode 100644 index 0000000..27e565e --- /dev/null +++ b/src/clje/core.clje @@ -0,0 +1,20 @@ +;; CljElixir Core Functions +;; Compound functions that dispatch through protocols + +(defmodule CljElixir.Core + (defn get-in [m ks] + (Enum/reduce ks m (fn [k acc] (CljElixir.ILookup/lookup acc k)))) + + (defn assoc-in [m ks v] + (let [k (hd ks) + rest-ks (tl ks)] + (if (= rest-ks []) + (CljElixir.IAssociative/assoc m k v) + (CljElixir.IAssociative/assoc m k (assoc-in (CljElixir.ILookup/lookup m k) rest-ks v))))) + + (defn update-in [m ks f] + (let [k (hd ks) + rest-ks (tl ks)] + (if (= rest-ks []) + (CljElixir.IAssociative/assoc m k (erlang/apply f [(CljElixir.ILookup/lookup m k)])) + (CljElixir.IAssociative/assoc m k (update-in (CljElixir.ILookup/lookup m k) rest-ks f)))))) diff --git a/src/clje/core/persistent_vector.clje b/src/clje/core/persistent_vector.clje new file mode 100644 index 0000000..d25cc8d --- /dev/null +++ b/src/clje/core/persistent_vector.clje @@ -0,0 +1,251 @@ +;; CljElixir PersistentVector — bit-partitioned trie +;; Ported from ClojureScript's PersistentVector +;; +;; BEAM adaptations: +;; - "Arrays" are BEAM tuples (immutable, O(1) indexed access via elem) +;; - aset = put-elem (returns new tuple) +;; - Tail grows via :erlang/append-element +;; +;; NOTE: Uses Map/get for struct field access (not keyword-as-function) +;; because ILookup is not yet implemented for these struct types. + +;; --------------------------------------------------------------------------- +;; VectorNode — trie node containing up to 32 children +;; --------------------------------------------------------------------------- + +(defrecord CljElixir.VectorNode [edit arr]) + +;; --------------------------------------------------------------------------- +;; PersistentVector — bit-partitioned trie with O(log32 n) indexed access +;; --------------------------------------------------------------------------- + +(defrecord CljElixir.PersistentVector [meta cnt shift root tail] + + ;; --- Constants as zero-arity functions --- + + (defn empty-node [] + (CljElixir.VectorNode/new nil (erlang/make-tuple 32 nil))) + + (defn empty-vec [] + (CljElixir.PersistentVector/new nil 0 5 (empty-node) (erlang/make-tuple 0 nil))) + + ;; --- Internal helpers --- + + (defn- tail-off [cnt] + (if (< cnt 32) + 0 + (erlang/bsl (erlang/bsr (dec cnt) 5) 5))) + + (defn- array-for [pv i] + (let [cnt (Map/get pv :cnt)] + (if (and (>= i 0) (< i cnt)) + (if (>= i (tail-off cnt)) + (Map/get pv :tail) + (loop [node (Map/get pv :root) + level (Map/get pv :shift)] + (if (> level 0) + (let [child-idx (erlang/band (erlang/bsr i level) 31)] + (recur (elem (Map/get node :arr) child-idx) + (- level 5))) + (Map/get node :arr)))) + (throw (str "Index " i " out of bounds for vector of size " cnt))))) + + ;; --- Nth --- + + (defn pv-nth + ([pv i] + (let [node (array-for pv i)] + (elem node (erlang/band i 31)))) + ([pv i not-found] + (if (and (>= i 0) (< i (Map/get pv :cnt))) + (pv-nth pv i) + not-found))) + + ;; --- Path operations --- + + (defn- new-path [level node] + (if (= level 0) + node + (let [new-arr (put-elem (erlang/make-tuple 32 nil) 0 (new-path (- level 5) node))] + (CljElixir.VectorNode/new nil new-arr)))) + + (defn- push-tail [cnt level parent tail-node] + (let [subidx (erlang/band (erlang/bsr (dec cnt) level) 31) + parent-arr (Map/get parent :arr) + node-to-insert + (if (= level 5) + tail-node + (let [child (elem parent-arr subidx)] + (if (not (nil? child)) + (push-tail cnt (- level 5) child tail-node) + (new-path (- level 5) tail-node)))) + new-arr (put-elem parent-arr subidx node-to-insert)] + (CljElixir.VectorNode/new nil new-arr))) + + ;; --- Conj (append) --- + + (defn pv-conj [pv val] + (let [cnt (Map/get pv :cnt) + tail (Map/get pv :tail) + tail-len (tuple-size tail) + meta (Map/get pv :meta) + shift (Map/get pv :shift) + root (Map/get pv :root)] + (if (< tail-len 32) + ;; Room in tail + (CljElixir.PersistentVector/new + meta (inc cnt) shift root + (erlang/append-element tail val)) + ;; Tail full — push into trie + (let [tail-node (CljElixir.VectorNode/new nil tail) + overflow? (> (erlang/bsr cnt 5) (erlang/bsl 1 shift))] + (if overflow? + ;; New root level + (let [new-arr (put-elem + (put-elem (erlang/make-tuple 32 nil) 0 root) + 1 (new-path shift tail-node))] + (CljElixir.PersistentVector/new + meta (inc cnt) (+ shift 5) + (CljElixir.VectorNode/new nil new-arr) + (erlang/make-tuple 1 val))) + ;; Room at current depth + (CljElixir.PersistentVector/new + meta (inc cnt) shift + (push-tail cnt shift root tail-node) + (erlang/make-tuple 1 val))))))) + + ;; --- Assoc (update at index) --- + + (defn- do-assoc [level node i val] + (let [node-arr (Map/get node :arr) + node-edit (Map/get node :edit)] + (if (= level 0) + (CljElixir.VectorNode/new node-edit + (put-elem node-arr (erlang/band i 31) val)) + (let [subidx (erlang/band (erlang/bsr i level) 31) + new-child (do-assoc (- level 5) (elem node-arr subidx) i val)] + (CljElixir.VectorNode/new node-edit + (put-elem node-arr subidx new-child)))))) + + (defn pv-assoc [pv i val] + (let [cnt (Map/get pv :cnt)] + (cond + (and (>= i 0) (< i cnt)) + (if (>= i (tail-off cnt)) + (CljElixir.PersistentVector/new + (Map/get pv :meta) cnt (Map/get pv :shift) (Map/get pv :root) + (put-elem (Map/get pv :tail) (erlang/band i 31) val)) + (CljElixir.PersistentVector/new + (Map/get pv :meta) cnt (Map/get pv :shift) + (do-assoc (Map/get pv :shift) (Map/get pv :root) i val) + (Map/get pv :tail))) + (= i cnt) + (pv-conj pv val) + true + (throw (str "Index " i " out of bounds for assoc on vector of size " cnt))))) + + ;; --- Pop (remove last) --- + + (defn- pop-tail [cnt level node] + (let [subidx (erlang/band (erlang/bsr (dec cnt) level) 31) + node-arr (Map/get node :arr) + node-edit (Map/get node :edit)] + (cond + (> level 5) + (let [new-child (pop-tail cnt (- level 5) (elem node-arr subidx))] + (if (and (nil? new-child) (= subidx 0)) + nil + (CljElixir.VectorNode/new node-edit + (put-elem node-arr subidx new-child)))) + (= subidx 0) + nil + true + (CljElixir.VectorNode/new node-edit + (put-elem node-arr subidx nil))))) + + (defn pv-pop [pv] + (let [cnt (Map/get pv :cnt)] + (cond + (= cnt 0) + (throw "Can't pop empty vector") + (= cnt 1) + (empty-vec) + true + (let [tail (Map/get pv :tail) + tail-len (tuple-size tail)] + (if (> tail-len 1) + ;; Shrink tail + (let [new-tail (List/to-tuple (lists/droplast (Tuple/to-list tail)))] + (CljElixir.PersistentVector/new + (Map/get pv :meta) (dec cnt) (Map/get pv :shift) (Map/get pv :root) new-tail)) + ;; Pull last leaf from trie + (let [new-tail (array-for pv (- cnt 2)) + shift (Map/get pv :shift) + new-root (pop-tail cnt shift (Map/get pv :root)) + new-root (if (nil? new-root) (empty-node) new-root) + squish? (and (> shift 5) (nil? (elem (Map/get new-root :arr) 1))) + new-root (if squish? (elem (Map/get new-root :arr) 0) new-root) + new-shift (if squish? (- shift 5) shift)] + (CljElixir.PersistentVector/new + (Map/get pv :meta) (dec cnt) new-shift new-root new-tail))))))) + + ;; --- Construction --- + + (defn from-list [xs] + (Enum/reduce xs (empty-vec) (fn [x acc] (CljElixir.PersistentVector/pv-conj acc x)))) + + (defn to-list [pv] + (let [cnt (Map/get pv :cnt)] + (if (= cnt 0) + (list) + (loop [i 0 + acc (list)] + (if (< i cnt) + (recur (inc i) (++ acc (list (pv-nth pv i)))) + acc))))) + + ;; --- Utility --- + + (defn pv-count [pv] + (Map/get pv :cnt)) + + (defn pv-with-meta [pv new-meta] + (CljElixir.PersistentVector/new + new-meta (Map/get pv :cnt) (Map/get pv :shift) (Map/get pv :root) (Map/get pv :tail)))) + +;; --------------------------------------------------------------------------- +;; SubVector — efficient view into an existing PersistentVector +;; --------------------------------------------------------------------------- + +(defrecord CljElixir.SubVector [meta v start end] + + (defn sv-new + ([v start] (CljElixir.SubVector/new nil v start (Map/get v :cnt))) + ([v start end-idx] + ;; If v is already a SubVector, flatten + (if (Kernel/is-struct v CljElixir.SubVector) + (CljElixir.SubVector/new nil (Map/get v :v) (+ (Map/get v :start) start) (+ (Map/get v :start) end-idx)) + (CljElixir.SubVector/new nil v start end-idx)))) + + (defn sv-count [sv] (- (Map/get sv :end) (Map/get sv :start))) + + (defn sv-nth + ([sv i] + (let [actual-i (+ (Map/get sv :start) i)] + (if (and (>= i 0) (< actual-i (Map/get sv :end))) + (CljElixir.PersistentVector/pv-nth (Map/get sv :v) actual-i) + (throw (str "Index " i " out of bounds for subvec of size " (sv-count sv)))))) + ([sv i not-found] + (let [actual-i (+ (Map/get sv :start) i)] + (if (and (>= i 0) (< actual-i (Map/get sv :end))) + (CljElixir.PersistentVector/pv-nth (Map/get sv :v) actual-i) + not-found)))) + + (defn sv-to-list [sv] + (let [start (Map/get sv :start) + end-idx (Map/get sv :end) + v (Map/get sv :v)] + (loop [i start acc (list)] + (if (< i end-idx) + (recur (inc i) (++ acc (list (CljElixir.PersistentVector/pv-nth v i)))) + acc))))) diff --git a/src/clje/core/protocols.clje b/src/clje/core/protocols.clje new file mode 100644 index 0000000..2ef27e7 --- /dev/null +++ b/src/clje/core/protocols.clje @@ -0,0 +1,420 @@ +;; CljElixir Core Protocols +;; Defines the fundamental protocols for CljElixir's data abstractions + +;; ---- Protocol Definitions ---- + +(defprotocol CljElixir.ILookup + (-lookup [o k] [o k not-found])) + +(defprotocol CljElixir.IAssociative + (-contains-key? [coll k]) + (-assoc [coll k v])) + +(defprotocol CljElixir.IMap + (-dissoc [coll k])) + +(defprotocol CljElixir.ICounted + (-count [coll])) + +(defprotocol CljElixir.ISeqable + (-seq [o])) + +(defprotocol CljElixir.ISeq + (-first [coll]) + (-rest [coll])) + +(defprotocol CljElixir.ICollection + (-conj [coll o])) + +(defprotocol CljElixir.IIndexed + (-nth [coll n] [coll n not-found])) + +(defprotocol CljElixir.IFn + (-invoke [o] [o a] [o a b] [o a b c])) + +(defprotocol CljElixir.IMeta + (-meta [o])) + +(defprotocol CljElixir.IWithMeta + (-with-meta [o meta])) + +(defprotocol CljElixir.IStack + (-peek [coll]) + (-pop [coll])) + +(defprotocol CljElixir.IMapEntry + (-key [coll]) + (-val [coll])) + +(defprotocol CljElixir.IKVReduce + (-kv-reduce [coll f init])) + +(defprotocol CljElixir.IHash + (-hash [o])) + +(defprotocol CljElixir.IEquiv + (-equiv [o other])) + +(defprotocol CljElixir.IClojurify + (-clojurify [o])) + +(defprotocol CljElixir.IElixirify + (-elixirify [o])) + +;; ---- Type Extensions ---- + +;; Map extensions +(extend-type Map + CljElixir.ILookup + (-lookup + ([o k] (Map/get o k)) + ([o k not-found] (Map/get o k not-found))) + + CljElixir.IAssociative + (-contains-key? [coll k] (Map/has-key? coll k)) + (-assoc [coll k v] (Map/put coll k v)) + + CljElixir.IMap + (-dissoc [coll k] (Map/delete coll k)) + + CljElixir.ICounted + (-count [coll] (map-size coll)) + + CljElixir.ISeqable + (-seq [o] (Map/to-list o)) + + CljElixir.ICollection + (-conj [coll o] (Map/merge coll o)) + + CljElixir.IFn + (-invoke + ([_o] (throw "map invoke requires at least one argument")) + ([o k] (Map/get o k)) + ([o k not-found] (Map/get o k not-found)) + ([_o _a _b _c] (throw "map invoke supports at most two arguments"))) + + CljElixir.IKVReduce + (-kv-reduce [coll f init] + (Enum/reduce (Map/to-list coll) init + (fn [entry acc] (erlang/apply f [acc (elem entry 0) (elem entry 1)])))) + + CljElixir.IHash + (-hash [o] (erlang/phash2 o)) + + CljElixir.IEquiv + (-equiv [o other] (= o other))) + +;; List extensions +(extend-type List + CljElixir.ISeq + (-first [coll] (if (= coll []) nil (hd coll))) + (-rest [coll] (if (= coll []) [] (tl coll))) + + CljElixir.ICounted + (-count [coll] (length coll)) + + CljElixir.ISeqable + (-seq [o] (if (= o []) nil o)) + + CljElixir.ICollection + (-conj [coll o] (cons o coll)) + + CljElixir.IStack + (-peek [coll] (if (= coll []) nil (hd coll))) + (-pop [coll] (if (= coll []) [] (tl coll))) + + CljElixir.IHash + (-hash [o] (erlang/phash2 o)) + + CljElixir.IEquiv + (-equiv [o other] (= o other))) + +;; Tuple extensions +(extend-type Tuple + CljElixir.ICounted + (-count [coll] (tuple-size coll)) + + CljElixir.IIndexed + (-nth + ([coll n] (elem coll n)) + ([coll n not-found] + (if (< n (tuple-size coll)) + (elem coll n) + not-found))) + + CljElixir.IHash + (-hash [o] (erlang/phash2 o)) + + CljElixir.IEquiv + (-equiv [o other] (= o other))) + +;; BitString extensions +(extend-type BitString + CljElixir.ICounted + (-count [coll] (byte-size coll))) + +;; PersistentVector extensions +(extend-type CljElixir.PersistentVector + CljElixir.ICounted + (-count [pv] (CljElixir.PersistentVector/pv-count pv)) + + CljElixir.IIndexed + (-nth + ([pv n] (CljElixir.PersistentVector/pv-nth pv n)) + ([pv n not-found] (CljElixir.PersistentVector/pv-nth pv n not-found))) + + CljElixir.ILookup + (-lookup + ([pv k] + (if (is-integer k) + (if (and (>= k 0) (< k (CljElixir.PersistentVector/pv-count pv))) + (CljElixir.PersistentVector/pv-nth pv k) + nil) + nil)) + ([pv k not-found] + (if (is-integer k) + (if (and (>= k 0) (< k (CljElixir.PersistentVector/pv-count pv))) + (CljElixir.PersistentVector/pv-nth pv k) + not-found) + not-found))) + + CljElixir.IAssociative + (-contains-key? [pv k] + (and (is-integer k) (>= k 0) (< k (CljElixir.PersistentVector/pv-count pv)))) + (-assoc [pv k v] (CljElixir.PersistentVector/pv-assoc pv k v)) + + CljElixir.ICollection + (-conj [pv val] (CljElixir.PersistentVector/pv-conj pv val)) + + CljElixir.ISeqable + (-seq [pv] + (if (= (CljElixir.PersistentVector/pv-count pv) 0) + nil + (CljElixir.PersistentVector/to-list pv))) + + CljElixir.ISeq + (-first [pv] + (if (= (CljElixir.PersistentVector/pv-count pv) 0) + nil + (CljElixir.PersistentVector/pv-nth pv 0))) + (-rest [pv] + (if (= (CljElixir.PersistentVector/pv-count pv) 0) + (list) + (tl (CljElixir.PersistentVector/to-list pv)))) + + CljElixir.IStack + (-peek [pv] + (if (= (CljElixir.PersistentVector/pv-count pv) 0) + nil + (CljElixir.PersistentVector/pv-nth pv (dec (CljElixir.PersistentVector/pv-count pv))))) + (-pop [pv] (CljElixir.PersistentVector/pv-pop pv)) + + CljElixir.IFn + (-invoke + ([_pv] (throw "vector invoke requires at least one argument")) + ([pv i] (CljElixir.PersistentVector/pv-nth pv i)) + ([_pv _a _b] (throw "vector invoke supports at most one argument")) + ([_pv _a _b _c] (throw "vector invoke supports at most one argument"))) + + CljElixir.IMeta + (-meta [pv] (Map/get pv :meta)) + + CljElixir.IWithMeta + (-with-meta [pv m] (CljElixir.PersistentVector/pv-with-meta pv m)) + + CljElixir.IKVReduce + (-kv-reduce [pv f init] + (let [cnt (CljElixir.PersistentVector/pv-count pv)] + (loop [i 0 + acc init] + (if (< i cnt) + (recur (inc i) (erlang/apply f [acc i (CljElixir.PersistentVector/pv-nth pv i)])) + acc)))) + + CljElixir.IHash + (-hash [pv] (erlang/phash2 (CljElixir.PersistentVector/to-list pv))) + + CljElixir.IEquiv + (-equiv [pv other] + (let [pv-list (CljElixir.PersistentVector/to-list pv)] + (cond + (is-list other) + (= pv-list other) + (vector? other) + (= pv-list (CljElixir.PersistentVector/to-list other)) + true + false)))) + +;; SubVector extensions +(extend-type CljElixir.SubVector + CljElixir.ICounted + (-count [sv] (CljElixir.SubVector/sv-count sv)) + + CljElixir.IIndexed + (-nth + ([sv n] (CljElixir.SubVector/sv-nth sv n)) + ([sv n not-found] (CljElixir.SubVector/sv-nth sv n not-found))) + + CljElixir.ILookup + (-lookup + ([sv k] + (if (is-integer k) + (if (and (>= k 0) (< k (CljElixir.SubVector/sv-count sv))) + (CljElixir.SubVector/sv-nth sv k) + nil) + nil)) + ([sv k not-found] + (if (is-integer k) + (if (and (>= k 0) (< k (CljElixir.SubVector/sv-count sv))) + (CljElixir.SubVector/sv-nth sv k) + not-found) + not-found))) + + CljElixir.ISeqable + (-seq [sv] + (if (= (CljElixir.SubVector/sv-count sv) 0) + nil + (CljElixir.SubVector/sv-to-list sv))) + + CljElixir.ISeq + (-first [sv] + (if (= (CljElixir.SubVector/sv-count sv) 0) + nil + (CljElixir.SubVector/sv-nth sv 0))) + (-rest [sv] + (if (= (CljElixir.SubVector/sv-count sv) 0) + (list) + (tl (CljElixir.SubVector/sv-to-list sv)))) + + CljElixir.IStack + (-peek [sv] + (if (= (CljElixir.SubVector/sv-count sv) 0) + nil + (CljElixir.SubVector/sv-nth sv (dec (CljElixir.SubVector/sv-count sv))))) + (-pop [sv] + (let [cnt (CljElixir.SubVector/sv-count sv)] + (if (= cnt 0) + (throw "Can't pop empty subvec") + (CljElixir.SubVector/sv-new (Map/get sv :v) (Map/get sv :start) (dec (Map/get sv :end)))))) + + CljElixir.IHash + (-hash [sv] (erlang/phash2 (CljElixir.SubVector/sv-to-list sv))) + + CljElixir.IEquiv + (-equiv [sv other] + (let [sv-list (CljElixir.SubVector/sv-to-list sv)] + (cond + (is-list other) + (= sv-list other) + (vector? other) + (= sv-list (CljElixir.PersistentVector/to-list other)) + true + false)))) + +;; Tuple sequence and collection extensions +(extend-type Tuple + CljElixir.ISeqable + (-seq [o] (if (= (tuple-size o) 0) nil (Tuple/to-list o))) + + CljElixir.ISeq + (-first [o] (if (= (tuple-size o) 0) nil (elem o 0))) + (-rest [o] (if (= (tuple-size o) 0) (list) (tl (Tuple/to-list o)))) + + CljElixir.ICollection + (-conj [o x] (erlang/append-element o x))) + +;; ---- Phase 4: Clojurify/Elixirify ---- + +;; IClojurify - deep convert BEAM types → CljElixir types +;; Tuples and lists become PersistentVectors; maps walk values; scalars pass through + +(extend-type Tuple + CljElixir.IClojurify + (-clojurify [o] + (CljElixir.PersistentVector/from-list + (Enum/map (Tuple/to-list o) (fn [x] (CljElixir.IClojurify/clojurify x)))))) + +(extend-type List + CljElixir.IClojurify + (-clojurify [o] + (CljElixir.PersistentVector/from-list + (Enum/map o (fn [x] (CljElixir.IClojurify/clojurify x)))))) + +(extend-type Map + CljElixir.IClojurify + (-clojurify [o] + (Map/new (Enum/map o (fn [entry] #el[(elem entry 0) (CljElixir.IClojurify/clojurify (elem entry 1))]))))) + +(extend-type CljElixir.PersistentVector + CljElixir.IClojurify + (-clojurify [o] + (CljElixir.PersistentVector/from-list + (Enum/map (CljElixir.PersistentVector/to-list o) (fn [x] (CljElixir.IClojurify/clojurify x)))))) + +(extend-type Atom + CljElixir.IClojurify + (-clojurify [o] o)) + +(extend-type Integer + CljElixir.IClojurify + (-clojurify [o] o)) + +(extend-type Float + CljElixir.IClojurify + (-clojurify [o] o)) + +(extend-type BitString + CljElixir.IClojurify + (-clojurify [o] o)) + +;; IElixirify - deep convert CljElixir types → BEAM types +;; PersistentVectors become lists; tuples walk elements; maps walk values; scalars pass through + +(extend-type CljElixir.PersistentVector + CljElixir.IElixirify + (-elixirify [o] + (Enum/map (CljElixir.PersistentVector/to-list o) (fn [x] (CljElixir.IElixirify/elixirify x))))) + +(extend-type List + CljElixir.IElixirify + (-elixirify [o] + (Enum/map o (fn [x] (CljElixir.IElixirify/elixirify x))))) + +(extend-type Map + CljElixir.IElixirify + (-elixirify [o] + (Map/new (Enum/map o (fn [entry] #el[(elem entry 0) (CljElixir.IElixirify/elixirify (elem entry 1))]))))) + +(extend-type Tuple + CljElixir.IElixirify + (-elixirify [o] + (erlang/list-to-tuple + (Enum/map (Tuple/to-list o) (fn [x] (CljElixir.IElixirify/elixirify x)))))) + +(extend-type Atom + CljElixir.IElixirify + (-elixirify [o] o)) + +(extend-type Integer + CljElixir.IElixirify + (-elixirify [o] o)) + +(extend-type Float + CljElixir.IElixirify + (-elixirify [o] o)) + +(extend-type BitString + CljElixir.IElixirify + (-elixirify [o] o)) + +;; SubVector clojurify/elixirify +(extend-type CljElixir.SubVector + CljElixir.IClojurify + (-clojurify [o] + (CljElixir.PersistentVector/from-list + (Enum/map (CljElixir.SubVector/sv-to-list o) (fn [x] (CljElixir.IClojurify/clojurify x)))))) + +(extend-type CljElixir.SubVector + CljElixir.IElixirify + (-elixirify [o] + (Enum/map (CljElixir.SubVector/sv-to-list o) (fn [x] (CljElixir.IElixirify/elixirify x))))) diff --git a/test/clj_elixir/compiler_test.exs b/test/clj_elixir/compiler_test.exs new file mode 100644 index 0000000..e2e7e85 --- /dev/null +++ b/test/clj_elixir/compiler_test.exs @@ -0,0 +1,151 @@ +defmodule CljElixir.CompilerTest do + use ExUnit.Case, async: true + + describe "compile_string/2" do + test "returns {:ok, ast} for valid source" do + # This test exercises the full pipeline. It requires Reader and Transformer + # to be implemented. Until then, it verifies the Compiler module compiles + # and the function heads are correct. + source = "(+ 1 2)" + + case CljElixir.Compiler.compile_string(source) do + {:ok, _ast} -> + :ok + + {:error, diagnostics} -> + # Expected when Reader/Transformer are not yet implemented + assert is_list(diagnostics) + end + end + + test "returns {:error, diagnostics} for missing file" do + {:error, diagnostics} = CljElixir.Compiler.compile_file("/nonexistent/path.clje") + assert is_list(diagnostics) + assert length(diagnostics) > 0 + + [diag | _] = diagnostics + assert diag.severity == :error + assert diag.message =~ "could not read file" + end + + test "passes file option through" do + source = "(+ 1 2)" + opts = [file: "test.clje"] + + case CljElixir.Compiler.compile_string(source, opts) do + {:ok, _ast} -> :ok + {:error, _diagnostics} -> :ok + end + end + end + + describe "eval_string/2" do + test "returns {:ok, result, bindings} or {:error, diagnostics}" do + source = "(+ 1 2)" + + case CljElixir.Compiler.eval_string(source) do + {:ok, result, bindings} -> + assert result == 3 + assert is_list(bindings) + + {:error, diagnostics} -> + # Expected when Reader/Transformer are not yet implemented + assert is_list(diagnostics) + end + end + end + + describe "compile_to_beam/2" do + test "returns {:ok, modules} or {:error, diagnostics}" do + source = ~S""" + (defmodule TestBeamCompile + (defn hello [] :world)) + """ + + case CljElixir.Compiler.compile_to_beam(source) do + {:ok, modules} -> + assert is_list(modules) + + assert Enum.any?(modules, fn {mod, _binary} -> + mod == TestBeamCompile + end) + + {:error, diagnostics} -> + # Expected when Reader/Transformer are not yet implemented + assert is_list(diagnostics) + end + end + end + + describe "compile_file/2" do + test "reads file and compiles" do + # Write a temp file + tmp_dir = System.tmp_dir!() + path = Path.join(tmp_dir, "test_compile_#{System.unique_integer([:positive])}.clje") + File.write!(path, "(+ 1 2)") + + try do + case CljElixir.Compiler.compile_file(path) do + {:ok, _ast} -> :ok + {:error, _diagnostics} -> :ok + end + after + File.rm(path) + end + end + + test "returns error for nonexistent file" do + {:error, [diag | _]} = CljElixir.Compiler.compile_file("/does/not/exist.clje") + assert diag.severity == :error + assert diag.message =~ "could not read file" + end + end + + describe "eval_file/2" do + test "reads file, compiles, and evaluates" do + tmp_dir = System.tmp_dir!() + path = Path.join(tmp_dir, "test_eval_#{System.unique_integer([:positive])}.clje") + File.write!(path, "(+ 1 2)") + + try do + case CljElixir.Compiler.eval_file(path) do + {:ok, 3, _bindings} -> :ok + {:ok, _result, _bindings} -> :ok + {:error, _diagnostics} -> :ok + end + after + File.rm(path) + end + end + end + + describe "compile_file_to_beam/2" do + test "compiles file and writes .beam output" do + tmp_dir = System.tmp_dir!() + source_path = Path.join(tmp_dir, "test_beam_#{System.unique_integer([:positive])}.clje") + output_dir = Path.join(tmp_dir, "beam_output_#{System.unique_integer([:positive])}") + + File.write!(source_path, ~S""" + (defmodule TestBeamOutput + (defn greet [] "hi")) + """) + + try do + case CljElixir.Compiler.compile_file_to_beam(source_path, output_dir: output_dir) do + {:ok, modules} -> + assert is_list(modules) + # Check .beam files were written + beam_files = Path.wildcard(Path.join(output_dir, "*.beam")) + assert length(beam_files) > 0 + + {:error, _diagnostics} -> + # Expected when Reader/Transformer are not yet implemented + :ok + end + after + File.rm(source_path) + File.rm_rf(output_dir) + end + end + end +end diff --git a/test/clj_elixir/malli_test.exs b/test/clj_elixir/malli_test.exs new file mode 100644 index 0000000..ac741de --- /dev/null +++ b/test/clj_elixir/malli_test.exs @@ -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 diff --git a/test/clj_elixir/phase2_test.exs b/test/clj_elixir/phase2_test.exs new file mode 100644 index 0000000..51b00a0 --- /dev/null +++ b/test/clj_elixir/phase2_test.exs @@ -0,0 +1,417 @@ +defmodule CljElixir.Phase2Test do + use ExUnit.Case, async: false + + # Helper to compile and evaluate CljElixir code + # Uses vector_as_list: true until PersistentVector is implemented (Phase 3 WS-3) + defp eval!(source) do + case CljElixir.Compiler.eval_string(source, vector_as_list: true) do + {:ok, result, _bindings} -> result + {:error, errors} -> raise "CljElixir eval error: #{inspect(errors)}" + end + end + + # Protocols and core modules are compiled by the Mix compiler plugin + # (compilers: [..., :clj_elixir] in mix.exs). No setup needed. + + # ========================================================================== + # ILookup - get + # ========================================================================== + + describe "get (ILookup)" do + test "get from map with existing key" do + assert eval!("(get {:a 1 :b 2} :a)") == 1 + end + + test "get from map with missing key returns nil" do + assert eval!("(get {:a 1} :b)") == nil + end + + test "get from map with missing key and default" do + assert eval!("(get {:a 1} :b 42)") == 42 + end + + test "get from map with existing key ignores default" do + assert eval!("(get {:a 1} :a 42)") == 1 + end + end + + # ========================================================================== + # IAssociative - assoc, contains? + # ========================================================================== + + describe "assoc (IAssociative)" do + test "assoc adds new key to map" do + assert eval!("(assoc {:a 1} :b 2)") == %{a: 1, b: 2} + end + + test "assoc updates existing key" do + assert eval!("(assoc {:a 1} :a 2)") == %{a: 2} + end + + test "assoc on empty map" do + assert eval!("(assoc {} :a 1)") == %{a: 1} + end + end + + describe "contains? (IAssociative)" do + test "contains? returns true for existing key" do + assert eval!("(contains? {:a 1 :b 2} :a)") == true + end + + test "contains? returns false for missing key" do + assert eval!("(contains? {:a 1} :c)") == false + end + end + + # ========================================================================== + # IMap - dissoc + # ========================================================================== + + describe "dissoc (IMap)" do + test "dissoc removes key from map" do + assert eval!("(dissoc {:a 1 :b 2} :a)") == %{b: 2} + end + + test "dissoc with missing key returns same map" do + assert eval!("(dissoc {:a 1} :b)") == %{a: 1} + end + end + + # ========================================================================== + # ICounted - count + # ========================================================================== + + describe "count (ICounted)" do + test "count of map" do + assert eval!("(count {:a 1 :b 2 :c 3})") == 3 + end + + test "count of list" do + assert eval!("(count (list 1 2 3))") == 3 + end + + test "count of empty map" do + assert eval!("(count {})") == 0 + end + + test "count of tuple" do + assert eval!("(count #el[1 2 3])") == 3 + end + + test "count of string" do + assert eval!("(count \"hello\")") == 5 + end + end + + # ========================================================================== + # ISeq - first, rest + # ========================================================================== + + describe "first/rest (ISeq)" do + test "first of list" do + assert eval!("(first (list 1 2 3))") == 1 + end + + test "rest of list" do + assert eval!("(rest (list 1 2 3))") == [2, 3] + end + + test "first of empty list" do + assert eval!("(first (list))") == nil + end + + test "rest of empty list" do + assert eval!("(rest (list))") == [] + end + end + + # ========================================================================== + # ISeqable - seq + # ========================================================================== + + describe "seq (ISeqable)" do + test "seq of non-empty list returns the list" do + assert eval!("(seq (list 1 2 3))") == [1, 2, 3] + end + + test "seq of empty list returns nil" do + assert eval!("(seq (list))") == nil + end + + test "seq of map returns key-value pairs" do + result = eval!("(seq {:a 1})") + assert is_list(result) + assert length(result) == 1 + end + end + + # ========================================================================== + # ICollection - conj + # ========================================================================== + + describe "conj (ICollection)" do + test "conj onto list prepends" do + assert eval!("(conj (list 2 3) 1)") == [1, 2, 3] + end + + test "conj onto map merges tuple entry" do + result = eval!("(conj {:a 1} {:b 2})") + assert result == %{a: 1, b: 2} + end + end + + # ========================================================================== + # IIndexed - nth + # ========================================================================== + + describe "nth (IIndexed)" do + test "nth from tuple" do + assert eval!("(nth #el[10 20 30] 1)") == 20 + end + + test "nth with default" do + assert eval!("(nth #el[10 20] 5 :not-found)") == :"not-found" + end + end + + # ========================================================================== + # IStack - peek, pop (via protocol on List) + # ========================================================================== + + # Note: peek and pop are not in the builtin dispatch yet, they go through + # the protocol directly - skip for now unless dispatch was added + + # ========================================================================== + # Sequence wrapper functions + # ========================================================================== + + describe "map (sequence function)" do + test "map over list" do + assert eval!("(map (fn [x] (inc x)) (list 1 2 3))") == [2, 3, 4] + end + end + + describe "filter" do + test "filter list" do + assert eval!("(filter (fn [x] (> x 2)) (list 1 2 3 4))") == [3, 4] + end + end + + describe "reduce" do + test "reduce with initial value" do + assert eval!("(reduce (fn [a b] (+ a b)) 0 (list 1 2 3))") == 6 + end + + test "reduce without initial value" do + assert eval!("(reduce (fn [a b] (+ a b)) (list 1 2 3))") == 6 + end + end + + describe "concat" do + test "concat two lists" do + assert eval!("(concat (list 1 2) (list 3 4))") == [1, 2, 3, 4] + end + end + + describe "take and drop" do + test "take from list" do + assert eval!("(take 2 (list 1 2 3 4))") == [1, 2] + end + + test "drop from list" do + assert eval!("(drop 2 (list 1 2 3 4))") == [3, 4] + end + end + + describe "sort" do + test "sort list" do + assert eval!("(sort (list 3 1 2))") == [1, 2, 3] + end + end + + describe "distinct" do + test "distinct removes duplicates" do + assert eval!("(distinct (list 1 2 1 3 2))") == [1, 2, 3] + end + end + + describe "frequencies" do + test "frequencies counts occurrences" do + assert eval!("(frequencies (list :a :b :a :c :b :a))") == %{a: 3, b: 2, c: 1} + end + end + + describe "partition" do + test "partition into chunks" do + assert eval!("(partition 2 (list 1 2 3 4))") == [[1, 2], [3, 4]] + end + end + + describe "mapcat" do + test "mapcat flattens results" do + assert eval!("(mapcat (fn [x] (list x x)) (list 1 2 3))") == [1, 1, 2, 2, 3, 3] + end + end + + # ========================================================================== + # Map-specific functions + # ========================================================================== + + describe "keys" do + test "keys of map" do + result = eval!("(keys {:a 1 :b 2})") + assert Enum.sort(result) == [:a, :b] + end + end + + describe "vals" do + test "vals of map" do + result = eval!("(vals {:a 1 :b 2})") + assert Enum.sort(result) == [1, 2] + end + end + + describe "merge" do + test "merge two maps" do + assert eval!("(merge {:a 1} {:b 2})") == %{a: 1, b: 2} + end + + test "merge with overwrite" do + assert eval!("(merge {:a 1} {:a 2})") == %{a: 2} + end + end + + describe "select-keys" do + test "select-keys from map" do + assert eval!("(select-keys {:a 1 :b 2 :c 3} (list :a :c))") == %{a: 1, c: 3} + end + end + + describe "into" do + test "into map from list of tuples" do + assert eval!("(into {} (list #el[:a 1] #el[:b 2]))") == %{a: 1, b: 2} + end + end + + # ========================================================================== + # update + # ========================================================================== + + describe "update" do + test "update map value with function" do + assert eval!("(update {:a 1} :a (fn [x] (inc x)))") == %{a: 2} + end + end + + # ========================================================================== + # empty? + # ========================================================================== + + describe "empty?" do + test "empty? on empty map" do + assert eval!("(empty? {})") == true + end + + test "empty? on non-empty map" do + assert eval!("(empty? {:a 1})") == false + end + end + + # ========================================================================== + # Keyword-as-function through ILookup + # ========================================================================== + + describe "keyword-as-function" do + test "keyword as function on map" do + assert eval!("(:name {:name \"Ada\"})") == "Ada" + end + + test "keyword with default value" do + assert eval!("(:age {:name \"Ada\"} 25)") == 25 + end + end + + # ========================================================================== + # Compound functions (get-in, assoc-in, update-in) + # ========================================================================== + + describe "get-in" do + test "get-in nested map" do + assert eval!("(get-in {:a {:b {:c 42}}} (list :a :b :c))") == 42 + end + + test "get-in with missing key" do + assert eval!("(get-in {:a {:b 1}} (list :a :c))") == nil + end + end + + describe "assoc-in" do + test "assoc-in nested map" do + assert eval!("(assoc-in {:a {:b 1}} (list :a :b) 2)") == %{a: %{b: 2}} + end + end + + describe "update-in" do + test "update-in nested map" do + assert eval!("(update-in {:a {:b 1}} (list :a :b) (fn [x] (inc x)))") == %{a: %{b: 2}} + end + end + + # ========================================================================== + # reduce-kv + # ========================================================================== + + describe "reduce-kv" do + test "reduce-kv over map" do + # Reduce a map collecting keys into a list + result = eval!("(reduce-kv (fn [acc _k v] (+ acc v)) 0 {:a 1 :b 2 :c 3})") + assert result == 6 + end + end + + # ========================================================================== + # FFI ? and ! preservation + # ========================================================================== + + describe "FFI name munging fix" do + test "Map/has-key? works correctly" do + assert eval!("(Map/has-key? {:a 1} :a)") == true + end + + test "Map/has-key? returns false for missing key" do + assert eval!("(Map/has-key? {:a 1} :b)") == false + end + end + + # ========================================================================== + # End-to-end integration + # ========================================================================== + + describe "end-to-end integration" do + test "realistic data transformation pipeline" do + source = """ + (let [data (list {:name "Alice" :age 30} + {:name "Bob" :age 25} + {:name "Carol" :age 35})] + (map (fn [p] (:name p)) (filter (fn [p] (> (get p :age) 28)) data))) + """ + assert eval!(source) == ["Alice", "Carol"] + end + + test "nested map operations" do + source = """ + (let [m {:a 1 :b 2 :c 3}] + (dissoc (assoc m :d 4) :b)) + """ + assert eval!(source) == %{a: 1, c: 3, d: 4} + end + + test "count and empty? together" do + source = """ + (let [m {:a 1}] + (list (count m) (empty? m) (empty? {}))) + """ + assert eval!(source) == [1, false, true] + end + end +end diff --git a/test/clj_elixir/phase3_test.exs b/test/clj_elixir/phase3_test.exs new file mode 100644 index 0000000..ebac773 --- /dev/null +++ b/test/clj_elixir/phase3_test.exs @@ -0,0 +1,452 @@ +defmodule CljElixir.Phase3Test do + use ExUnit.Case, async: false + + # Evaluate CljElixir code with PersistentVector enabled (no vector_as_list flag) + defp eval!(source) do + case CljElixir.Compiler.eval_string(source) do + {:ok, result, _bindings} -> result + {:error, errors} -> raise "CljElixir eval error: #{inspect(errors)}" + end + end + + # ========================================================================== + # Vector literal construction + # ========================================================================== + + describe "vector literal construction" do + test "vector literal produces PersistentVector" do + result = eval!("[1 2 3]") + assert result.__struct__ == CljElixir.PersistentVector + end + + test "empty vector literal produces PersistentVector" do + result = eval!("[]") + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.pv_count(result) == 0 + end + + test "single element vector" do + result = eval!("[42]") + assert CljElixir.PersistentVector.pv_count(result) == 1 + assert CljElixir.PersistentVector.pv_nth(result, 0) == 42 + end + + test "nested vectors" do + result = eval!("[[1 2] [3 4]]") + assert CljElixir.PersistentVector.pv_count(result) == 2 + inner = CljElixir.PersistentVector.pv_nth(result, 0) + assert inner.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.pv_nth(inner, 0) == 1 + end + + test "vector with mixed types" do + result = eval!("[1 :two \"three\" nil true]") + assert CljElixir.PersistentVector.pv_count(result) == 5 + assert CljElixir.PersistentVector.pv_nth(result, 0) == 1 + assert CljElixir.PersistentVector.pv_nth(result, 1) == :two + assert CljElixir.PersistentVector.pv_nth(result, 2) == "three" + assert CljElixir.PersistentVector.pv_nth(result, 3) == nil + assert CljElixir.PersistentVector.pv_nth(result, 4) == true + end + end + + # ========================================================================== + # vec and vector builtins + # ========================================================================== + + describe "vec and vector builtins" do + test "vec converts list to PersistentVector" do + result = eval!("(vec (list 1 2 3))") + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.pv_count(result) == 3 + end + + test "vector creates PersistentVector from args" do + result = eval!("(vector 4 5 6)") + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [4, 5, 6] + end + + test "vector with no args creates empty vector" do + result = eval!("(vector)") + assert CljElixir.PersistentVector.pv_count(result) == 0 + end + + test "vector? returns true for PersistentVector" do + assert eval!("(vector? [1 2 3])") == true + end + + test "vector? returns false for list" do + assert eval!("(vector? (list 1 2 3))") == false + end + + test "vector? returns false for map" do + assert eval!("(vector? {:a 1})") == false + end + end + + # ========================================================================== + # Protocol dispatch — indexed access + # ========================================================================== + + describe "indexed access" do + test "nth on vector" do + assert eval!("(nth [10 20 30] 0)") == 10 + assert eval!("(nth [10 20 30] 1)") == 20 + assert eval!("(nth [10 20 30] 2)") == 30 + end + + test "nth with not-found" do + assert eval!("(nth [10 20 30] 5 :missing)") == :missing + end + + test "get on vector" do + assert eval!("(get [10 20 30] 1)") == 20 + end + + test "get with not-found" do + assert eval!("(get [10 20 30] 5 :missing)") == :missing + end + + test "get with non-integer key returns nil" do + assert eval!("(get [10 20 30] :foo)") == nil + end + end + + # ========================================================================== + # Protocol dispatch — mutation operations + # ========================================================================== + + describe "mutation operations" do + test "conj appends to vector" do + result = eval!("(conj [1 2] 3)") + assert CljElixir.PersistentVector.to_list(result) == [1, 2, 3] + end + + test "assoc updates index" do + result = eval!("(assoc [1 2 3] 1 :x)") + assert CljElixir.PersistentVector.to_list(result) == [1, :x, 3] + end + + test "assoc at end appends" do + result = eval!("(assoc [1 2] 2 3)") + assert CljElixir.PersistentVector.to_list(result) == [1, 2, 3] + end + + test "contains? with valid index" do + assert eval!("(contains? [1 2 3] 0)") == true + assert eval!("(contains? [1 2 3] 2)") == true + end + + test "contains? with invalid index" do + assert eval!("(contains? [1 2 3] 3)") == false + assert eval!("(contains? [1 2 3] -1)") == false + end + end + + # ========================================================================== + # Protocol dispatch — sequence operations + # ========================================================================== + + describe "sequence operations" do + test "count on vector" do + assert eval!("(count [1 2 3])") == 3 + assert eval!("(count [])") == 0 + end + + test "first on vector" do + assert eval!("(first [10 20 30])") == 10 + end + + test "first on empty vector" do + assert eval!("(first [])") == nil + end + + test "seq on non-empty vector returns list" do + result = eval!("(seq [1 2 3])") + assert is_list(result) + assert result == [1, 2, 3] + end + + test "seq on empty vector returns nil" do + assert eval!("(seq [])") == nil + end + + test "empty? on vector" do + assert eval!("(empty? [])") == true + assert eval!("(empty? [1])") == false + end + end + + # ========================================================================== + # Stack operations (peek/pop) + # ========================================================================== + + describe "stack operations" do + test "peek returns last element" do + assert eval!("(peek [10 20 30])") == 30 + end + + test "pop removes last element" do + result = eval!("(pop [1 2 3])") + assert CljElixir.PersistentVector.to_list(result) == [1, 2] + end + + test "pop single element returns empty" do + result = eval!("(pop [42])") + assert CljElixir.PersistentVector.pv_count(result) == 0 + end + end + + # ========================================================================== + # Vector as function (IFn) + # ========================================================================== + + describe "vector as function" do + # TODO: Vector-as-function requires transformer support for struct invocation. + # The IFn protocol is implemented but Elixir doesn't auto-dispatch when a + # struct is in call position. Needs a transformer change to detect and wrap + # non-function call heads with IFn dispatch. + end + + # ========================================================================== + # Pattern matching (unchanged — vectors match tuples in patterns) + # ========================================================================== + + describe "pattern matching" do + test "vector in case pattern matches tuple" do + assert eval!("(case #el[:ok 42] [:ok x] x)") == 42 + end + + test "vector in let pattern matches tuple" do + assert eval!("(let [[:ok x] #el[:ok 99]] x)") == 99 + end + + test "nested vector patterns" do + result = eval!(""" + (case #el[:ok #el[:inner 5]] + [:ok [:inner n]] n + _ nil) + """) + assert result == 5 + end + end + + # ========================================================================== + # Metadata + # ========================================================================== + + describe "metadata" do + test "empty vector has nil meta" do + result = eval!("[]") + assert Map.get(result, :meta) == nil + end + end + + # ========================================================================== + # Boundary conditions (trie level transitions) + # ========================================================================== + + describe "boundary conditions" do + test "32 element vector (full tail, no trie)" do + v = CljElixir.PersistentVector.from_list(Enum.to_list(1..32)) + assert CljElixir.PersistentVector.pv_count(v) == 32 + assert CljElixir.PersistentVector.pv_nth(v, 0) == 1 + assert CljElixir.PersistentVector.pv_nth(v, 31) == 32 + end + + test "33 element vector (trie overflow)" do + v = CljElixir.PersistentVector.from_list(Enum.to_list(1..33)) + assert CljElixir.PersistentVector.pv_count(v) == 33 + assert CljElixir.PersistentVector.pv_nth(v, 0) == 1 + assert CljElixir.PersistentVector.pv_nth(v, 32) == 33 + end + + test "1025 element vector (multi-level trie)" do + v = CljElixir.PersistentVector.from_list(Enum.to_list(1..1025)) + assert CljElixir.PersistentVector.pv_count(v) == 1025 + assert CljElixir.PersistentVector.pv_nth(v, 0) == 1 + assert CljElixir.PersistentVector.pv_nth(v, 1024) == 1025 + end + + test "conj across 32-element boundary" do + v32 = CljElixir.PersistentVector.from_list(Enum.to_list(1..32)) + v33 = CljElixir.PersistentVector.pv_conj(v32, 33) + assert CljElixir.PersistentVector.pv_count(v33) == 33 + assert CljElixir.PersistentVector.pv_nth(v33, 32) == 33 + # Original unchanged (structural sharing) + assert CljElixir.PersistentVector.pv_count(v32) == 32 + end + + test "pop across 33-to-32 boundary" do + v33 = CljElixir.PersistentVector.from_list(Enum.to_list(1..33)) + v32 = CljElixir.PersistentVector.pv_pop(v33) + assert CljElixir.PersistentVector.pv_count(v32) == 32 + assert CljElixir.PersistentVector.pv_nth(v32, 31) == 32 + end + + test "assoc in trie (not tail)" do + v33 = CljElixir.PersistentVector.from_list(Enum.to_list(1..33)) + v33b = CljElixir.PersistentVector.pv_assoc(v33, 0, :first) + assert CljElixir.PersistentVector.pv_nth(v33b, 0) == :first + assert CljElixir.PersistentVector.pv_nth(v33b, 1) == 2 + # Original unchanged + assert CljElixir.PersistentVector.pv_nth(v33, 0) == 1 + end + end + + # ========================================================================== + # SubVector + # ========================================================================== + + describe "subvec" do + test "subvec creates view into vector" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([1, 2, 3, 4, 5]), 1, 4) + assert CljElixir.SubVector.sv_count(sv) == 3 + assert CljElixir.SubVector.sv_nth(sv, 0) == 2 + assert CljElixir.SubVector.sv_nth(sv, 1) == 3 + assert CljElixir.SubVector.sv_nth(sv, 2) == 4 + end + + test "subvec to_list" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([1, 2, 3, 4, 5]), 1, 4) + assert CljElixir.SubVector.sv_to_list(sv) == [2, 3, 4] + end + + test "subvec 2-arity (start to end)" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([10, 20, 30]), 1) + assert CljElixir.SubVector.sv_count(sv) == 2 + assert CljElixir.SubVector.sv_to_list(sv) == [20, 30] + end + + test "subvec nth with not-found" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([1, 2, 3]), 0, 2) + assert CljElixir.SubVector.sv_nth(sv, 5, :missing) == :missing + end + end + + # ========================================================================== + # Cross-type equality + # ========================================================================== + + describe "cross-type equality" do + test "vector equals list with same elements" do + assert eval!("(= [1 2 3] (list 1 2 3))") == true + end + + test "vector not equal to list with different elements" do + assert eval!("(= [1 2 3] (list 1 2 4))") == false + end + + test "two vectors with same elements are equal" do + assert eval!("(= [1 2 3] [1 2 3])") == true + end + + test "two vectors with different elements are not equal" do + assert eval!("(= [1 2] [1 2 3])") == false + end + + test "not= works with cross-type" do + assert eval!("(not= [1 2 3] (list 1 2 3))") == false + assert eval!("(not= [1 2 3] (list 4 5 6))") == true + end + + test "scalar equality still works" do + assert eval!("(= 1 1)") == true + assert eval!("(= 1 2)") == false + assert eval!("(= :a :a)") == true + end + end + + # ========================================================================== + # Enumerable/Collectable protocols + # ========================================================================== + + describe "Enumerable and Collectable" do + test "Enum.map over PersistentVector" do + pv = CljElixir.PersistentVector.from_list([1, 2, 3]) + result = Enum.map(pv, &(&1 * 2)) + assert result == [2, 4, 6] + end + + test "Enum.filter over PersistentVector" do + pv = CljElixir.PersistentVector.from_list([1, 2, 3, 4, 5]) + result = Enum.filter(pv, &(rem(&1, 2) == 0)) + assert result == [2, 4] + end + + test "Enum.count on PersistentVector" do + pv = CljElixir.PersistentVector.from_list([1, 2, 3]) + assert Enum.count(pv) == 3 + end + + test "Enum.into PersistentVector" do + pv = Enum.into([1, 2, 3], CljElixir.PersistentVector.from_list([])) + assert pv.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(pv) == [1, 2, 3] + end + + test "Enum.slice on PersistentVector" do + pv = CljElixir.PersistentVector.from_list([10, 20, 30, 40, 50]) + assert Enum.slice(pv, 1, 3) == [20, 30, 40] + end + end + + # ========================================================================== + # SubVector protocol dispatch + # ========================================================================== + + describe "SubVector protocols" do + test "count via protocol" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([1, 2, 3, 4, 5]), 1, 4) + assert CljElixir.ICounted.count(sv) == 3 + end + + test "nth via protocol" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([10, 20, 30, 40]), 1, 3) + assert CljElixir.IIndexed.nth(sv, 0) == 20 + assert CljElixir.IIndexed.nth(sv, 1) == 30 + end + + test "lookup via protocol" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([10, 20, 30]), 0, 2) + assert CljElixir.ILookup.lookup(sv, 0) == 10 + assert CljElixir.ILookup.lookup(sv, 5) == nil + assert CljElixir.ILookup.lookup(sv, 5, :missing) == :missing + end + + test "seq via protocol" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([1, 2, 3, 4]), 1, 3) + assert CljElixir.ISeqable.seq(sv) == [2, 3] + end + + test "first/rest via protocol" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([10, 20, 30]), 0, 3) + assert CljElixir.ISeq.first(sv) == 10 + assert CljElixir.ISeq.rest(sv) == [20, 30] + end + + test "peek/pop via protocol" do + sv = CljElixir.SubVector.sv_new(CljElixir.PersistentVector.from_list([1, 2, 3, 4, 5]), 1, 4) + assert CljElixir.IStack.peek(sv) == 4 + popped = CljElixir.IStack.pop(sv) + assert CljElixir.SubVector.sv_count(popped) == 2 + end + end + + # ========================================================================== + # Existing Phase 2 tests still pass (backward compatibility) + # ========================================================================== + + describe "backward compatibility" do + test "maps still work with protocols" do + assert eval!("(get {:a 1 :b 2} :a)") == 1 + assert eval!("(count {:a 1 :b 2})") == 2 + end + + test "lists still work with protocols" do + assert eval!("(first (list 10 20 30))") == 10 + assert eval!("(count (list 1 2 3))") == 3 + end + end +end diff --git a/test/clj_elixir/phase4_test.exs b/test/clj_elixir/phase4_test.exs new file mode 100644 index 0000000..06e342b --- /dev/null +++ b/test/clj_elixir/phase4_test.exs @@ -0,0 +1,392 @@ +defmodule CljElixir.Phase4Test do + use ExUnit.Case, async: false + + # Evaluate CljElixir code with PersistentVector enabled + defp eval!(source) do + case CljElixir.Compiler.eval_string(source) do + {:ok, result, _bindings} -> result + {:error, errors} -> raise "CljElixir eval error: #{inspect(errors)}" + end + end + + # ========================================================================== + # tuple function + # ========================================================================== + + describe "tuple function" do + test "empty tuple" do + result = eval!("(tuple)") + assert result == {} + end + + test "single element tuple" do + result = eval!("(tuple :ok)") + assert result == {:ok} + end + + test "two element tuple" do + result = eval!("(tuple :ok \"data\")") + assert result == {:ok, "data"} + end + + test "three element tuple" do + result = eval!("(tuple 1 2 3)") + assert result == {1, 2, 3} + end + + test "tuple with mixed types" do + result = eval!("(tuple :error 404 \"not found\")") + assert result == {:error, 404, "not found"} + end + + test "tuple with nested tuple" do + result = eval!("(tuple :ok (tuple 1 2))") + assert result == {:ok, {1, 2}} + end + + test "tuple-size on constructed tuple" do + result = eval!("(tuple-size (tuple :a :b :c))") + assert result == 3 + end + + test "elem on constructed tuple" do + result = eval!("(elem (tuple :a :b :c) 1)") + assert result == :b + end + + test "tuple in let binding" do + result = eval!(""" + (let [t (tuple :ok 42)] + (elem t 1)) + """) + assert result == 42 + end + + test "tuple with expressions as arguments" do + result = eval!("(tuple (+ 1 2) (* 3 4))") + assert result == {3, 12} + end + end + + # ========================================================================== + # clojurify + # ========================================================================== + + describe "clojurify" do + test "tuple to vector" do + result = eval!("(clojurify #el[:ok \"data\"])") + assert result.__struct__ == CljElixir.PersistentVector + list = CljElixir.PersistentVector.to_list(result) + assert list == [:ok, "data"] + end + + test "list to vector" do + result = eval!("(clojurify '(1 2 3))") + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [1, 2, 3] + end + + test "nested tuple deep conversion" do + result = eval!("(clojurify #el[:ok #el[:nested \"data\"]])") + assert result.__struct__ == CljElixir.PersistentVector + list = CljElixir.PersistentVector.to_list(result) + assert hd(list) == :ok + inner = hd(tl(list)) + assert inner.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(inner) == [:nested, "data"] + end + + test "map values walked" do + result = eval!("(clojurify {:a #el[1 2]})") + assert is_map(result) + inner = Map.get(result, :a) + assert inner.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(inner) == [1, 2] + end + + test "vector idempotent" do + result = eval!("(clojurify [1 2 3])") + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [1, 2, 3] + end + + test "scalar passthrough - integer" do + assert eval!("(clojurify 42)") == 42 + end + + test "scalar passthrough - string" do + assert eval!("(clojurify \"hello\")") == "hello" + end + + test "scalar passthrough - atom" do + assert eval!("(clojurify :foo)") == :foo + end + + test "scalar passthrough - nil" do + assert eval!("(clojurify nil)") == nil + end + end + + # ========================================================================== + # elixirify + # ========================================================================== + + describe "elixirify" do + test "vector to list" do + result = eval!("(elixirify [1 2 3])") + assert is_list(result) + assert result == [1, 2, 3] + end + + test "nested vector deep conversion" do + result = eval!("(elixirify [:ok [:nested \"data\"]])") + assert is_list(result) + assert result == [:ok, [:nested, "data"]] + end + + test "map values walked" do + result = eval!("(elixirify {:a [1 2]})") + assert is_map(result) + assert Map.get(result, :a) == [1, 2] + end + + test "list idempotent" do + result = eval!("(elixirify '(1 2 3))") + assert is_list(result) + assert result == [1, 2, 3] + end + + test "tuple elements walked" do + result = eval!("(elixirify #el[:ok [1 2]])") + assert is_tuple(result) + assert elem(result, 0) == :ok + assert is_list(elem(result, 1)) + assert elem(result, 1) == [1, 2] + end + + test "scalar passthrough - integer" do + assert eval!("(elixirify 42)") == 42 + end + + test "scalar passthrough - string" do + assert eval!("(elixirify \"hello\")") == "hello" + end + + test "scalar passthrough - atom" do + assert eval!("(elixirify :foo)") == :foo + end + end + + # ========================================================================== + # Integration: roundtrips and composition + # ========================================================================== + + describe "roundtrip conversions" do + test "clojurify then elixirify roundtrip on tuple" do + result = eval!(""" + (elixirify (clojurify #el[:ok "data"])) + """) + assert is_list(result) + assert result == [:ok, "data"] + end + + test "elixirify then clojurify roundtrip on vector" do + result = eval!(""" + (clojurify (elixirify [1 2 3])) + """) + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [1, 2, 3] + end + + test "deep nested roundtrip" do + result = eval!(""" + (elixirify (clojurify #el[:ok #el[1 #el[2 3]]])) + """) + assert is_list(result) + assert result == [:ok, [1, [2, 3]]] + end + + test "map with nested roundtrip" do + result = eval!(""" + (elixirify (clojurify {:a #el[1 2] :b #el[3 4]})) + """) + assert is_map(result) + assert Map.get(result, :a) == [1, 2] + assert Map.get(result, :b) == [3, 4] + end + end + + describe "tuple function with clojurify/elixirify" do + test "tuple function result can be clojurified" do + result = eval!(""" + (clojurify (tuple :ok "data")) + """) + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [:ok, "data"] + end + + test "elixirify vector matches tuple construction" do + # elixirify produces a list, not a tuple (by spec) + result = eval!("(elixirify [1 2 3])") + assert is_list(result) + assert result == [1, 2, 3] + end + + test "tuple-size on tuple function result" do + result = eval!("(tuple-size (tuple :a :b :c :d))") + assert result == 4 + end + end + + describe "composition with core functions" do + test "map over list then clojurify" do + result = eval!(""" + (clojurify (Enum/map '(1 2 3) (fn [x] (* x 2)))) + """) + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [2, 4, 6] + end + + test "elixirify vector for Enum interop" do + result = eval!(""" + (Enum/sum (elixirify [1 2 3 4 5])) + """) + assert result == 15 + end + + test "clojurify in let binding" do + result = eval!(""" + (let [v (clojurify #el[:ok 42])] + (nth v 1)) + """) + assert result == 42 + end + + test "elixirify in let binding" do + result = eval!(""" + (let [lst (elixirify [10 20 30])] + (hd lst)) + """) + assert result == 10 + end + end + + # ========================================================================== + # SubVector clojurify/elixirify + # ========================================================================== + + describe "SubVector clojurify" do + test "clojurify subvec returns vector" do + result = eval!(""" + (let [v [1 2 3 4 5] + sv (subvec v 1 4)] + (clojurify sv)) + """) + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [2, 3, 4] + end + end + + describe "SubVector elixirify" do + test "elixirify subvec returns list" do + result = eval!(""" + (let [v [1 2 3 4 5] + sv (subvec v 1 4)] + (elixirify sv)) + """) + assert is_list(result) + assert result == [2, 3, 4] + end + end + + # ========================================================================== + # Protocol extensibility + # ========================================================================== + + describe "protocol extensibility" do + test "defrecord can extend IElixirify" do + result = eval!(""" + (defmodule TestUser + (defrecord User [name age] + CljElixir.IElixirify + (-elixirify [u] {:name (Map/get u :name) :age (Map/get u :age) :type "user"}))) + (let [u (TestUser.User/new "Alice" 30)] + (elixirify u)) + """) + assert is_map(result) + assert Map.get(result, :name) == "Alice" + assert Map.get(result, :age) == 30 + assert Map.get(result, :type) == "user" + end + + test "defrecord can extend IClojurify" do + result = eval!(""" + (defmodule TestPoint + (defrecord Point [x y] + CljElixir.IClojurify + (-clojurify [p] [(Map/get p :x) (Map/get p :y)]))) + (let [p (TestPoint.Point/new 10 20)] + (clojurify p)) + """) + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [10, 20] + end + end + + # ========================================================================== + # Tuple sequence and collection operations + # ========================================================================== + + describe "tuple sequence operations" do + test "seq on tuple" do + result = eval!("(seq #el[1 2 3])") + assert is_list(result) + assert result == [1, 2, 3] + end + + test "seq on empty tuple" do + result = eval!("(seq (tuple))") + assert result == nil + end + + test "first on tuple" do + result = eval!("(first #el[:a :b :c])") + assert result == :a + end + + test "rest on tuple" do + result = eval!("(rest #el[:a :b :c])") + assert is_list(result) + assert result == [:b, :c] + end + + test "conj on tuple" do + result = eval!("(conj #el[1 2] 3)") + assert is_tuple(result) + assert result == {1, 2, 3} + end + + test "into empty tuple from vector" do + result = eval!("(into (tuple) [1 2 3])") + assert is_tuple(result) + assert result == {1, 2, 3} + end + + test "into vector from tuple" do + result = eval!("(into [] #el[1 2 3])") + assert result.__struct__ == CljElixir.PersistentVector + assert CljElixir.PersistentVector.to_list(result) == [1, 2, 3] + end + + test "into empty tuple from list" do + result = eval!("(into (tuple) '(1 2 3))") + assert is_tuple(result) + assert result == {1, 2, 3} + end + + test "count on tuple via seq" do + result = eval!("(count #el[1 2 3 4])") + assert result == 4 + end + end +end diff --git a/test/clj_elixir/phase5_test.exs b/test/clj_elixir/phase5_test.exs new file mode 100644 index 0000000..958e06c --- /dev/null +++ b/test/clj_elixir/phase5_test.exs @@ -0,0 +1,133 @@ +defmodule CljElixir.Phase5Test do + use ExUnit.Case, async: false + + # Helper to compile and evaluate CljElixir code + defp eval!(source) do + case CljElixir.Compiler.eval_string(source) do + {:ok, result, _bindings} -> result + {:error, errors} -> raise "CljElixir eval error: #{inspect(errors)}" + end + end + + # ========================================================================== + # GenServer integration + # ========================================================================== + + describe "GenServer - simple counter" do + test "define and use a counter GenServer" do + eval!(""" + (defmodule TestCounter + (use GenServer) + + (defn init [initial] + #el[:ok initial]) + + (defn handle-call + ([:get _from state] + #el[:reply state state]) + ([:increment _from state] + #el[:reply :ok (+ state 1)]))) + """) + + {:ok, pid} = GenServer.start_link(TestCounter, 0) + assert GenServer.call(pid, :get) == 0 + assert GenServer.call(pid, :increment) == :ok + assert GenServer.call(pid, :get) == 1 + GenServer.stop(pid) + end + end + + describe "GenServer - handle_cast" do + test "cast resets state" do + eval!(""" + (defmodule TestCaster + (use GenServer) + + (defn init [initial] + #el[:ok initial]) + + (defn handle-call + ([:get _from state] + #el[:reply state state])) + + (defn handle-cast + ([:reset _state] + #el[:noreply 0]))) + """) + + {:ok, pid} = GenServer.start_link(TestCaster, 42) + assert GenServer.call(pid, :get) == 42 + GenServer.cast(pid, :reset) + Process.sleep(50) + assert GenServer.call(pid, :get) == 0 + GenServer.stop(pid) + end + end + + describe "GenServer - handle_info" do + test "handle-info receives plain messages" do + eval!(""" + (defmodule TestInfoHandler + (use GenServer) + + (defn init [initial] + #el[:ok initial]) + + (defn handle-call + ([:get _from state] + #el[:reply state state])) + + (defn handle-info + ([:bump state] + #el[:noreply (+ state 1)]))) + """) + + {:ok, pid} = GenServer.start_link(TestInfoHandler, 0) + send(pid, :bump) + Process.sleep(50) + assert GenServer.call(pid, :get) == 1 + GenServer.stop(pid) + end + end + + # ========================================================================== + # ChatRoom pattern: spawn + send + receive loop + # ========================================================================== + + describe "ChatRoom pattern" do + test "spawn + send + receive loop" do + eval!(""" + (defmodule TestChatLoop + (defn loop [state] + (receive + [:ping pid] + (do + (send pid #el[:pong state]) + (TestChatLoop/loop (+ state 1))) + [:get pid] + (do + (send pid #el[:count state]) + (TestChatLoop/loop state)) + :stop + :stopped + :after 5000 + :timeout))) + """) + + pid = spawn(fn -> TestChatLoop.loop(0) end) + + send(pid, {:ping, self()}) + assert_receive {:pong, 0}, 1000 + + send(pid, {:ping, self()}) + assert_receive {:pong, 1}, 1000 + + send(pid, {:get, self()}) + assert_receive {:count, 2}, 1000 + + send(pid, :stop) + Process.sleep(50) + refute Process.alive?(pid) + end + end +end diff --git a/test/clj_elixir/phase6_test.exs b/test/clj_elixir/phase6_test.exs new file mode 100644 index 0000000..4a9fe15 --- /dev/null +++ b/test/clj_elixir/phase6_test.exs @@ -0,0 +1,494 @@ +defmodule CljElixir.Phase6Test do + use ExUnit.Case, async: false + + # Helper to compile and evaluate CljElixir code + defp eval!(source) do + case CljElixir.Compiler.eval_string(source) do + {:ok, result, _bindings} -> result + {:error, errors} -> raise "CljElixir eval error: #{inspect(errors)}" + end + end + + # ========================================================================== + # Thread-first (->) + # ========================================================================== + + describe "-> (thread-first)" do + test "single value passthrough: (-> 1) => 1" do + assert eval!("(-> 1)") == 1 + end + + test "basic threading with bare symbols: (-> 1 inc inc) => 3" do + assert eval!("(-> 1 inc inc)") == 3 + end + + test "threading into multi-arg function: (-> \"hello\" (str \" world\"))" do + assert eval!("(-> \"hello\" (str \" world\"))") == "hello world" + end + + test "threading with arithmetic: (-> 5 inc (+ 10)) => 16" do + assert eval!("(-> 5 inc (+ 10))") == 16 + end + + test "threading with module calls: (-> \"hello\" (String/upcase))" do + assert eval!("(-> \"hello\" (String/upcase))") == "HELLO" + end + + test "threading into first position of list operations" do + # (-> [1 2 3] (Enum/at 0)) => 1 + assert eval!("(-> [1 2 3] (Enum/at 0))") == 1 + end + + test "nested threading" do + # (-> 1 (-> inc inc)) is valid — inner -> produces 3? No: + # (-> 1 inc (+ (-> 10 dec))) = (+ (inc 1) (dec 10)) = (+ 2 9) = 11 + assert eval!("(-> 1 inc (+ (-> 10 dec)))") == 11 + end + + test "thread-first with let binding" do + result = eval!(""" + (let [x 5] + (-> x inc inc)) + """) + assert result == 7 + end + + test "threading with comparison" do + assert eval!("(-> 5 inc (> 3))") == true + end + end + + # ========================================================================== + # Thread-last (->>) + # ========================================================================== + + describe "->> (thread-last)" do + test "single value passthrough: (->> 1) => 1" do + assert eval!("(->> 1)") == 1 + end + + test "thread-last with bare symbols: (->> 1 inc inc) => 3" do + assert eval!("(->> 1 inc inc)") == 3 + end + + test "thread-last inserts as last argument" do + # (->> 1 (+ 10)) => (+ 10 1) => 11 + assert eval!("(->> 1 (+ 10))") == 11 + end + + test "thread-last with map over list" do + # (->> [1 2 3] (map (fn [x] (inc x)))) => (map (fn [x] (inc x)) [1 2 3]) => [2 3 4] + assert eval!("(->> [1 2 3] (map (fn [x] (inc x))))") == [2, 3, 4] + end + + test "thread-last with filter" do + # (->> [1 2 3 4 5] (filter (fn [x] (> x 2)))) => [3, 4, 5] + assert eval!("(->> [1 2 3 4 5] (filter (fn [x] (> x 2))))") == [3, 4, 5] + end + + test "thread-last chaining collection ops" do + # (->> [1 2 3 4 5] (map (fn [x] (inc x))) (filter (fn [x] (> x 3)))) + # => (filter (fn [x] (> x 3)) (map (fn [x] (inc x)) [1 2 3 4 5])) + # => (filter (fn [x] (> x 3)) [2 3 4 5 6]) + # => [4 5 6] + assert eval!("(->> [1 2 3 4 5] (map (fn [x] (inc x))) (filter (fn [x] (> x 3))))") == [4, 5, 6] + end + + test "nested thread-last" do + assert eval!("(->> 10 dec (+ (->> 1 inc)))") == 11 + end + end + + # ========================================================================== + # Mixed / edge cases + # ========================================================================== + + describe "threading edge cases" do + test "threading with keyword-as-function" do + # (-> {:name "Alice"} :name) => "Alice" + assert eval!("(-> {:name \"Alice\"} :name)") == "Alice" + end + + test "thread-first string operations" do + assert eval!("(-> \"hello world\" (String/upcase) (String/split \" \"))") == ["HELLO", "WORLD"] + end + + test "deeply nested threading" do + # (-> 0 inc inc inc inc inc) => 5 + assert eval!("(-> 0 inc inc inc inc inc)") == 5 + end + + test "thread-first with dec" do + assert eval!("(-> 10 dec dec dec)") == 7 + end + + test "thread-last with Enum/reduce" do + # (->> [1 2 3 4] (Enum/sum)) => 10 + assert eval!("(->> [1 2 3 4] (Enum/sum))") == 10 + end + end + + # ========================================================================== + # try / catch / finally + # ========================================================================== + + describe "try/catch/finally" do + test "try with rescue catches exception" do + result = eval!("(try (throw \"boom\") (catch e (str \"caught: \" (Exception/message e))))") + assert result == "caught: boom" + end + + test "try with typed rescue" do + result = eval!(""" + (try + (throw "boom") + (catch RuntimeError e + (str "runtime: " (Exception/message e)))) + """) + assert result == "runtime: boom" + end + + test "try with finally" do + # finally runs but doesn't affect return value + result = eval!(""" + (try + 42 + (finally (println "cleanup"))) + """) + assert result == 42 + end + + test "try with catch :throw" do + result = eval!(""" + (try + (Kernel/throw :oops) + (catch :throw val val)) + """) + assert result == :oops + end + + test "try with catch :exit" do + result = eval!(""" + (try + (Kernel/exit :shutdown) + (catch :exit reason reason)) + """) + assert result == :shutdown + end + + test "try returns body value when no exception" do + result = eval!("(try (+ 1 2) (catch e e))") + assert result == 3 + end + + test "try with multiple catch clauses" do + result = eval!(""" + (try + (throw "oops") + (catch ArgumentError e :arg_error) + (catch RuntimeError e :runtime_error)) + """) + assert result == :runtime_error + end + + test "try with rescue and finally" do + result = eval!(""" + (try + (throw "oops") + (catch e :caught) + (finally (println "done"))) + """) + assert result == :caught + end + end + + # ========================================================================== + # & rest variadic params + # ========================================================================== + + describe "& rest variadic params" do + test "defn with & rest, no rest args (uses default [])" do + result = eval!(""" + (defmodule VarTest1 + (defn foo [x & rest] + (count rest))) + (VarTest1/foo 1) + """) + assert result == 0 + end + + test "defn with & rest, with rest args passed as list" do + result = eval!(""" + (defmodule VarTest2 + (defn foo [x & rest] + rest)) + (VarTest2/foo 1 (list 2 3 4)) + """) + assert result == [2, 3, 4] + end + + test "defn with & rest uses rest in body" do + result = eval!(""" + (defmodule VarTest3 + (defn foo [x & rest] + (+ x (count rest)))) + (VarTest3/foo 10) + """) + assert result == 10 + end + + test "defn with & rest, multiple required params" do + result = eval!(""" + (defmodule VarTest4 + (defn foo [a b & rest] + (+ a b (count rest)))) + (VarTest4/foo 1 2) + """) + assert result == 3 + end + + test "defn with & rest, with rest args and multiple required params" do + result = eval!(""" + (defmodule VarTest4b + (defn foo [a b & rest] + (+ a b (count rest)))) + (VarTest4b/foo 1 2 (list 10 20 30)) + """) + assert result == 6 + end + + test "fn with & rest called inline" do + # Call the fn inline since let-bound fn variable calls aren't supported yet + result = eval!(""" + ((fn [x & rest] (+ x (count rest))) 5 (list 1 2 3)) + """) + assert result == 8 + end + + test "defn with only & rest param" do + result = eval!(""" + (defmodule VarTest5 + (defn foo [& args] + (count args))) + (VarTest5/foo) + """) + assert result == 0 + end + + test "defn with only & rest param, with args" do + result = eval!(""" + (defmodule VarTest6 + (defn foo [& args] + args)) + (VarTest6/foo (list 1 2 3)) + """) + assert result == [1, 2, 3] + end + end + + # ========================================================================== + # Destructuring + # ========================================================================== + + describe "destructuring" do + test "map :keys destructuring in let" do + result = eval!(""" + (let [{:keys [name age]} {:name "alice" :age 30}] + (str name " is " age)) + """) + assert result == "alice is 30" + end + + test "map :keys with :as" do + result = eval!(""" + (let [{:keys [name] :as person} {:name "bob" :age 25}] + (str name " " (count person))) + """) + # count on a map returns number of k/v pairs + assert result == "bob 2" + end + + test "map :strs destructuring" do + result = eval!(""" + (let [{:strs [name]} {"name" "charlie"}] + name) + """) + assert result == "charlie" + end + + test "map destructuring with literal keys" do + result = eval!(""" + (let [{x :x y :y} {:x 1 :y 2}] + (+ x y)) + """) + assert result == 3 + end + + test "sequential destructuring with & rest in let" do + result = eval!(""" + (let [[a b & rest] (list 1 2 3 4 5)] + rest) + """) + assert result == [3, 4, 5] + end + + test "sequential destructuring without rest" do + # Without &, vector in pattern still matches tuple + result = eval!(""" + (let [[a b] #el[1 2]] + (+ a b)) + """) + assert result == 3 + end + + test "map :keys in defn params" do + result = eval!(""" + (defmodule DestructTest1 + (defn greet [{:keys [name greeting]}] + (str greeting " " name))) + (DestructTest1/greet {:name "alice" :greeting "hi"}) + """) + assert result == "hi alice" + end + + test "sequential destructuring in fn params" do + # Call fn inline since let-bound fn variable calls aren't supported yet + result = eval!(""" + ((fn [[a b & rest]] (+ a b (count rest))) (list 10 20 30 40)) + """) + assert result == 32 + end + + test "nested map destructuring" do + result = eval!(""" + (let [{:keys [name] {:keys [city]} :address} {:name "alice" :address {:city "NYC"}}] + (str name " in " city)) + """) + assert result == "alice in NYC" + end + + test "map :keys in for binding" do + result = eval!(""" + (for [{:keys [name]} (list {:name "a"} {:name "b"} {:name "c"})] + name) + """) + assert result == ["a", "b", "c"] + end + + test "sequential destructuring in for with &" do + result = eval!(""" + (for [[a b & _rest] (list (list 1 2 99) (list 3 4 99))] + (+ a b)) + """) + assert result == [3, 7] + end + + test "map destructuring with hyphenated keys" do + result = eval!(""" + (let [{:keys [first-name]} {:"first-name" "alice"}] + first-name) + """) + assert result == "alice" + end + end + + # ========================================================================== + # defmacro + # ========================================================================== + + describe "defmacro" do + test "simple macro - unless" do + result = eval!(""" + (defmodule MacroTest1 + (defmacro unless [test then] + `(if (not ~test) ~then)) + (defn check [x] + (unless (> x 0) :negative))) + (MacroTest1/check -5) + """) + assert result == :negative + end + + test "macro with & body and splice-unquote" do + result = eval!(""" + (defmodule MacroTest2 + (defmacro unless [test & body] + `(if (not ~test) (do ~@body))) + (defn check [x] + (unless (> x 0) :negative))) + (MacroTest2/check -1) + """) + assert result == :negative + end + + test "macro with multiple body forms" do + result = eval!(""" + (defmodule MacroTest3 + (defmacro unless [test & body] + `(if (not ~test) (do ~@body))) + (defn check [x] + (unless (> x 0) + (println "not positive") + :negative))) + (MacroTest3/check -1) + """) + assert result == :negative + end + + test "macro expands correctly with complex expressions" do + result = eval!(""" + (defmodule MacroTest4 + (defmacro when-positive [x & body] + `(if (> ~x 0) (do ~@body))) + (defn test-it [n] + (when-positive n + (+ n 10)))) + (MacroTest4/test-it 5) + """) + assert result == 15 + end + + test "macro returns nil when condition not met" do + result = eval!(""" + (defmodule MacroTest5 + (defmacro when-positive [x & body] + `(if (> ~x 0) (do ~@body))) + (defn test-it [n] + (when-positive n + (+ n 10)))) + (MacroTest5/test-it -5) + """) + assert result == nil + end + + test "auto-gensym in macro" do + result = eval!(""" + (defmodule MacroTest6 + (defmacro my-let1 [val & body] + `(let [result# ~val] + (do ~@body))) + (defn use-it [] + (my-let1 42 + :ok))) + (MacroTest6/use-it) + """) + assert result == :ok + end + + test "multiple macros in same module" do + result = eval!(""" + (defmodule MacroTest7 + (defmacro unless [test & body] + `(if (not ~test) (do ~@body))) + (defmacro when-positive [x & body] + `(if (> ~x 0) (do ~@body))) + (defn check [x] + (if (when-positive x (> x 10)) + :big + (unless (> x 0) :non-positive)))) + (MacroTest7/check 20) + """) + assert result == :big + end + end +end diff --git a/test/clj_elixir/phase7_test.exs b/test/clj_elixir/phase7_test.exs new file mode 100644 index 0000000..f44803c --- /dev/null +++ b/test/clj_elixir/phase7_test.exs @@ -0,0 +1,232 @@ +defmodule CljElixir.Phase7Test do + use ExUnit.Case, async: false + + defp eval!(source) do + case CljElixir.Compiler.eval_string(source) do + {:ok, result, _} -> result + {:error, errors} -> raise "Compilation failed: #{inspect(errors)}" + end + end + + defp compile!(source) do + case CljElixir.Compiler.compile_to_beam(source) do + {:ok, modules} -> modules + {:error, errors} -> raise "Compilation failed: #{inspect(errors)}" + end + end + + describe "m/=> function specs" do + test "simple function spec compiles" do + compile!(""" + (defmodule SpecTest1 + (defn hello [name] + (str "hello " name)) + (m/=> hello [:=> [:cat :string] :string])) + """) + + assert true + after + :code.purge(SpecTest1) + :code.delete(SpecTest1) + end + + test "multi-param function spec" do + compile!(""" + (defmodule SpecTest2 + (defn add [a b] + (+ a b)) + (m/=> add [:=> [:cat :int :int] :int])) + """) + assert true + after + :code.purge(SpecTest2) + :code.delete(SpecTest2) + end + + test "optional param generates multiple specs" do + compile!(""" + (defmodule SpecTest3 + (defn greet + ([name] (greet name "hello")) + ([name greeting] (str greeting " " name))) + (m/=> greet [:=> [:cat :string [:? :string]] :string])) + """) + assert true + after + :code.purge(SpecTest3) + :code.delete(SpecTest3) + end + + test "multi-arity via :function" do + compile!(""" + (defmodule SpecTest4 + (defn greet + ([name] (greet name "hello")) + ([name greeting] (str greeting " " name))) + (m/=> greet [:function + [:=> [:cat :string] :string] + [:=> [:cat :string :string] :string]])) + """) + assert true + after + :code.purge(SpecTest4) + :code.delete(SpecTest4) + end + + test "spec with various types" do + compile!(""" + (defmodule SpecTest5 + (defn process [x] + x) + (m/=> process [:=> [:cat :any] [:or :int :string :nil]])) + """) + assert true + after + :code.purge(SpecTest5) + :code.delete(SpecTest5) + end + + test "spec with map type" do + compile!(""" + (defmodule SpecTest6 + (defn get-name [user] + (:name user)) + (m/=> get-name [:=> [:cat [:map [:name :string] [:age :int]]] :string])) + """) + assert true + after + :code.purge(SpecTest6) + :code.delete(SpecTest6) + end + + test "spec with tuple return" do + compile!(""" + (defmodule SpecTest7 + (defn fetch [id] + #el[:ok id]) + (m/=> fetch [:=> [:cat :int] [:tuple :atom :int]])) + """) + assert true + after + :code.purge(SpecTest7) + :code.delete(SpecTest7) + end + end + + describe "auto @type from def schemas" do + test "def with map schema generates type" do + compile!(""" + (defmodule TypeTest1 + (def User [:map [:name :string] [:age :int]]) + (defn get-name [user] + (:name user))) + """) + assert true + after + :code.purge(TypeTest1) + :code.delete(TypeTest1) + end + + test "def with or schema" do + compile!(""" + (defmodule TypeTest2 + (def Status [:enum :active :inactive :pending]) + (defn check [s] s)) + """) + assert true + after + :code.purge(TypeTest2) + :code.delete(TypeTest2) + end + + test "def with and schema" do + compile!(""" + (defmodule TypeTest3 + (def PositiveInt [:and :int [:> 0]]) + (defn check [n] n)) + """) + assert true + after + :code.purge(TypeTest3) + :code.delete(TypeTest3) + end + end + + describe "schema cross-references" do + test "spec references a named schema type" do + compile!(""" + (defmodule CrossRefTest1 + (def User [:map [:name :string] [:age :int]]) + (defn get-user [id] + {:name "alice" :age 30}) + (m/=> get-user [:=> [:cat :int] User])) + """) + assert true + after + :code.purge(CrossRefTest1) + :code.delete(CrossRefTest1) + end + + test "schema references another schema" do + compile!(""" + (defmodule CrossRefTest2 + (def PositiveInt [:and :int [:> 0]]) + (def Config [:map + [:host :string] + [:port PositiveInt] + [:ssl? :boolean]]) + (defn load-config [] + {:host "localhost" :port 8080 :"ssl?" true})) + """) + assert true + after + :code.purge(CrossRefTest2) + :code.delete(CrossRefTest2) + end + end + + describe "recursive schemas" do + test "recursive schema with registry" do + compile!(""" + (defmodule RecursiveTest1 + (def Tree [:schema {:registry {:tree [:or :int [:tuple [:ref :tree] [:ref :tree]]]}} [:ref :tree]]) + (defn make-leaf [n] n)) + """) + assert true + after + :code.purge(RecursiveTest1) + :code.delete(RecursiveTest1) + end + end + + describe "functions still work correctly" do + test "module with spec can be called" do + result = eval!(""" + (defmodule FuncSpecTest1 + (defn hello [name] + (str "hello " name)) + (m/=> hello [:=> [:cat :string] :string])) + (FuncSpecTest1/hello "world") + """) + assert result == "hello world" + after + :code.purge(FuncSpecTest1) + :code.delete(FuncSpecTest1) + end + + test "module with type and spec" do + result = eval!(""" + (defmodule FuncSpecTest2 + (def User [:map [:name :string] [:age :int]]) + (defn make-user [name age] + {:name name :age age}) + (m/=> make-user [:=> [:cat :string :int] User])) + (FuncSpecTest2/make-user "alice" 30) + """) + assert result == %{name: "alice", age: 30} + after + :code.purge(FuncSpecTest2) + :code.delete(FuncSpecTest2) + end + end +end diff --git a/test/clj_elixir/reader_test.exs b/test/clj_elixir/reader_test.exs new file mode 100644 index 0000000..96a2e69 --- /dev/null +++ b/test/clj_elixir/reader_test.exs @@ -0,0 +1,820 @@ +defmodule CljElixir.ReaderTest do + use ExUnit.Case, async: true + + alias CljElixir.Reader + + # ═══════════════════════════════════════════════════════════════════ + # Helpers + # ═══════════════════════════════════════════════════════════════════ + + defp read!(source) do + {:ok, forms} = Reader.read_string(source) + forms + end + + defp read_one!(source) do + [form] = read!(source) + form + end + + # ═══════════════════════════════════════════════════════════════════ + # Literal types + # ═══════════════════════════════════════════════════════════════════ + + describe "integers" do + test "positive integer" do + assert read_one!("42") == 42 + end + + test "zero" do + assert read_one!("0") == 0 + end + + test "negative integer" do + assert read_one!("-3") == -3 + end + + test "multi-digit" do + assert read_one!("12345") == 12345 + end + end + + describe "floats" do + test "simple float" do + assert read_one!("3.14") == 3.14 + end + + test "negative float" do + assert read_one!("-2.5") == -2.5 + end + + test "float starting with zero" do + assert read_one!("0.001") == 0.001 + end + end + + describe "strings" do + test "simple string" do + assert read_one!(~s("hello")) == "hello" + end + + test "empty string" do + assert read_one!(~s("")) == "" + end + + test "string with spaces" do + assert read_one!(~s("hello world")) == "hello world" + end + + test "escaped double quote" do + assert read_one!(~s("say \\"hi\\"")) == ~s(say "hi") + end + + test "escaped backslash" do + assert read_one!(~s("path\\\\to")) == "path\\to" + end + + test "escaped newline" do + assert read_one!(~s("line1\\nline2")) == "line1\nline2" + end + + test "escaped tab" do + assert read_one!(~s("col1\\tcol2")) == "col1\tcol2" + end + + test "escaped carriage return" do + assert read_one!(~s("before\\rafter")) == "before\rafter" + end + + test "multiline string" do + input = ~s("line1\nline2") + assert read_one!(input) == "line1\nline2" + end + end + + describe "keywords" do + test "simple keyword" do + assert read_one!(":ok") == :ok + end + + test "keyword with hyphen" do + assert read_one!(":my-key") == :"my-key" + end + + test "keyword with numbers" do + assert read_one!(":v2") == :v2 + end + + test "quoted keyword" do + assert read_one!(~s(:"quoted-name")) == :"quoted-name" + end + + test "keyword with question mark" do + assert read_one!(":empty?") == :empty? + end + + test "keyword with exclamation" do + assert read_one!(":reset!") == :reset! + end + end + + describe "booleans" do + test "true" do + assert read_one!("true") == true + end + + test "false" do + assert read_one!("false") == false + end + end + + describe "nil" do + test "nil literal" do + assert read_one!("nil") == nil + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Symbols + # ═══════════════════════════════════════════════════════════════════ + + describe "symbols" do + test "simple symbol" do + assert read_one!("hello") == {:symbol, %{line: 1, col: 1}, "hello"} + end + + test "symbol with hyphen" do + assert read_one!("my-func") == {:symbol, %{line: 1, col: 1}, "my-func"} + end + + test "qualified symbol with slash" do + assert read_one!("Enum/map") == {:symbol, %{line: 1, col: 1}, "Enum/map"} + end + + test "erlang module call" do + assert read_one!("io/format") == {:symbol, %{line: 1, col: 1}, "io/format"} + end + + test "dynamic var *name*" do + assert read_one!("*self*") == {:symbol, %{line: 1, col: 1}, "*self*"} + end + + test "symbol with question mark" do + assert read_one!("empty?") == {:symbol, %{line: 1, col: 1}, "empty?"} + end + + test "symbol with exclamation" do + assert read_one!("swap!") == {:symbol, %{line: 1, col: 1}, "swap!"} + end + + test "operator symbols" do + assert read_one!("+") == {:symbol, %{line: 1, col: 1}, "+"} + assert read_one!("-") == {:symbol, %{line: 1, col: 1}, "-"} + assert read_one!("*") == {:symbol, %{line: 1, col: 1}, "*"} + assert read_one!(">=") == {:symbol, %{line: 1, col: 1}, ">="} + assert read_one!("<=") == {:symbol, %{line: 1, col: 1}, "<="} + assert read_one!("!=") == {:symbol, %{line: 1, col: 1}, "!="} + end + + test "underscore symbol" do + assert read_one!("_") == {:symbol, %{line: 1, col: 1}, "_"} + end + + test "anon fn arg %" do + assert read_one!("%") == {:symbol, %{line: 1, col: 1}, "%"} + end + + test "anon fn numbered args %1 %2" do + assert read_one!("%1") == {:symbol, %{line: 1, col: 1}, "%1"} + assert read_one!("%2") == {:symbol, %{line: 1, col: 1}, "%2"} + end + + test "ampersand for rest args" do + assert read_one!("&") == {:symbol, %{line: 1, col: 1}, "&"} + end + + test "defn- private function name" do + assert read_one!("defn-") == {:symbol, %{line: 1, col: 1}, "defn-"} + end + + test "symbol starting with dot" do + assert read_one!(".method") == {:symbol, %{line: 1, col: 1}, ".method"} + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Collection types + # ═══════════════════════════════════════════════════════════════════ + + describe "lists" do + test "simple list" do + {:list, meta, elements} = read_one!("(+ 1 2)") + assert meta == %{line: 1, col: 1} + assert elements == [{:symbol, %{line: 1, col: 2}, "+"}, 1, 2] + end + + test "empty list" do + assert read_one!("()") == {:list, %{line: 1, col: 1}, []} + end + + test "nested list" do + {:list, _, [_, {:list, inner_meta, inner_elems}]} = read_one!("(a (b c))") + assert inner_meta == %{line: 1, col: 4} + assert inner_elems == [{:symbol, %{line: 1, col: 5}, "b"}, {:symbol, %{line: 1, col: 7}, "c"}] + end + end + + describe "vectors" do + test "simple vector" do + {:vector, meta, elements} = read_one!("[1 2 3]") + assert meta == %{line: 1, col: 1} + assert elements == [1, 2, 3] + end + + test "empty vector" do + assert read_one!("[]") == {:vector, %{line: 1, col: 1}, []} + end + + test "vector with mixed types" do + {:vector, _, elems} = read_one!("[:ok 42 \"hello\"]") + assert elems == [:ok, 42, "hello"] + end + end + + describe "maps" do + test "simple map" do + {:map, meta, elements} = read_one!("{:name \"Ada\" :age 30}") + assert meta == %{line: 1, col: 1} + assert elements == [:name, "Ada", :age, 30] + end + + test "empty map" do + assert read_one!("{}") == {:map, %{line: 1, col: 1}, []} + end + + test "map with nested values" do + {:map, _, elements} = read_one!("{:a [1 2] :b {:c 3}}") + assert length(elements) == 4 + assert Enum.at(elements, 0) == :a + assert {:vector, _, [1, 2]} = Enum.at(elements, 1) + assert Enum.at(elements, 2) == :b + assert {:map, _, [:c, 3]} = Enum.at(elements, 3) + end + end + + describe "sets" do + test "simple set" do + {:set, meta, elements} = read_one!("\#{:a :b :c}") + assert meta == %{line: 1, col: 1} + assert elements == [:a, :b, :c] + end + + test "empty set" do + assert read_one!("\#{}") == {:set, %{line: 1, col: 1}, []} + end + + test "nested set containing a set" do + {:set, _, elements} = read_one!("\#{\#{:a}}") + assert [inner] = elements + assert {:set, _, [:a]} = inner + end + end + + describe "tuples (#el[...])" do + test "simple tuple" do + {:tuple, meta, elements} = read_one!("#el[:ok value]") + assert meta == %{line: 1, col: 1} + assert elements == [:ok, {:symbol, %{line: 1, col: 9}, "value"}] + end + + test "empty tuple" do + assert read_one!("#el[]") == {:tuple, %{line: 1, col: 1}, []} + end + + test "tuple with nested data" do + {:tuple, _, elements} = read_one!("#el[:ok {:name \"Ada\"}]") + assert Enum.at(elements, 0) == :ok + assert {:map, _, [:name, "Ada"]} = Enum.at(elements, 1) + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Nested structures + # ═══════════════════════════════════════════════════════════════════ + + describe "nested structures" do + test "deeply nested list" do + {:list, _, [_, {:list, _, [_, {:list, _, [sym]}]}]} = read_one!("(a (b (c)))") + assert sym == {:symbol, %{line: 1, col: 8}, "c"} + end + + test "vector inside map inside list" do + {:list, _, [sym, {:map, _, [:data, {:vector, _, [1, 2, 3]}]}]} = + read_one!("(process {:data [1 2 3]})") + + assert sym == {:symbol, %{line: 1, col: 2}, "process"} + end + + test "let binding form" do + {:list, _, [let_sym, {:vector, _, bindings}, body]} = + read_one!("(let [x 1 y 2] (+ x y))") + + assert let_sym == {:symbol, %{line: 1, col: 2}, "let"} + assert bindings == [{:symbol, %{line: 1, col: 7}, "x"}, 1, {:symbol, %{line: 1, col: 11}, "y"}, 2] + assert {:list, _, [{:symbol, _, "+"}, {:symbol, _, "x"}, {:symbol, _, "y"}]} = body + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Prefix forms + # ═══════════════════════════════════════════════════════════════════ + + describe "quote" do + test "quote a list" do + {:quote, meta, inner} = read_one!("'(1 2 3)") + assert meta == %{line: 1, col: 1} + assert {:list, _, [1, 2, 3]} = inner + end + + test "quote a symbol" do + {:quote, _, inner} = read_one!("'hello") + assert inner == {:symbol, %{line: 1, col: 2}, "hello"} + end + end + + describe "quasiquote" do + test "quasiquote a list" do + {:quasiquote, meta, inner} = read_one!("`(list ~x ~@rest)") + assert meta == %{line: 1, col: 1} + assert {:list, _, [_, {:unquote, _, _}, {:splice_unquote, _, _}]} = inner + end + end + + describe "unquote" do + test "unquote a symbol" do + {:unquote, meta, inner} = read_one!("~x") + assert meta == %{line: 1, col: 1} + assert inner == {:symbol, %{line: 1, col: 2}, "x"} + end + end + + describe "splice-unquote" do + test "splice-unquote a symbol" do + {:splice_unquote, meta, inner} = read_one!("~@items") + assert meta == %{line: 1, col: 1} + assert inner == {:symbol, %{line: 1, col: 3}, "items"} + end + end + + describe "deref" do + test "deref a symbol" do + {:deref, meta, inner} = read_one!("@my-atom") + assert meta == %{line: 1, col: 1} + assert inner == {:symbol, %{line: 1, col: 2}, "my-atom"} + end + end + + describe "metadata" do + test "map metadata" do + {:with_meta, meta, {meta_map, target}} = + read_one!("^{:doc \"hello\"} my-fn") + + assert meta == %{line: 1, col: 1} + assert {:map, _, [:doc, "hello"]} = meta_map + assert target == {:symbol, %{line: 1, col: 17}, "my-fn"} + end + + test "keyword metadata shorthand" do + {:with_meta, _, {meta_map, target}} = + read_one!("^:private my-fn") + + assert {:map, _, [:private, true]} = meta_map + assert target == {:symbol, %{line: 1, col: 11}, "my-fn"} + end + + test "metadata on a vector" do + {:with_meta, _, {meta_map, target}} = + read_one!("^:dynamic [1 2]") + + assert {:map, _, [:dynamic, true]} = meta_map + assert {:vector, _, [1, 2]} = target + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Anonymous function shorthand + # ═══════════════════════════════════════════════════════════════════ + + describe "anonymous function #(...)" do + test "simple anon fn" do + {:anon_fn, meta, body} = read_one!("#(* % 2)") + assert meta == %{line: 1, col: 1} + assert {:list, _, [{:symbol, _, "*"}, {:symbol, _, "%"}, 2]} = body + end + + test "anon fn with multiple args" do + {:anon_fn, _, body} = read_one!("#(+ %1 %2)") + assert {:list, _, [{:symbol, _, "+"}, {:symbol, _, "%1"}, {:symbol, _, "%2"}]} = body + end + + test "anon fn with nested call" do + {:anon_fn, _, body} = read_one!("#(str \"hello \" %)") + assert {:list, _, [{:symbol, _, "str"}, "hello ", {:symbol, _, "%"}]} = body + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Regex literals + # ═══════════════════════════════════════════════════════════════════ + + describe "regex literals" do + test "simple regex" do + {:regex, meta, pattern} = read_one!(~s(#"pattern")) + assert meta == %{line: 1, col: 1} + assert pattern == "pattern" + end + + test "regex with special chars" do + {:regex, _, pattern} = read_one!(~s(#"^\\d{3}-\\d{4}$")) + assert pattern == "^\\d{3}-\\d{4}$" + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Comments and whitespace + # ═══════════════════════════════════════════════════════════════════ + + describe "comments" do + test "single-line comment ignored" do + forms = read!("; this is a comment\n42") + assert forms == [42] + end + + test "comment after form" do + forms = read!("42 ; a number") + assert forms == [42] + end + + test "multiple comments" do + forms = read!("; comment 1\n; comment 2\n42") + assert forms == [42] + end + + test "comment between forms" do + forms = read!("1\n; between\n2") + assert forms == [1, 2] + end + end + + describe "whitespace handling" do + test "commas are whitespace" do + {:vector, _, elems} = read_one!("[1, 2, 3]") + assert elems == [1, 2, 3] + end + + test "commas in maps" do + {:map, _, elems} = read_one!("{:a 1, :b 2}") + assert elems == [:a, 1, :b, 2] + end + + test "tabs and spaces" do + forms = read!(" \t 42") + assert forms == [42] + end + + test "multiple newlines" do + forms = read!("\n\n42\n\n") + assert forms == [42] + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Edge cases + # ═══════════════════════════════════════════════════════════════════ + + describe "negative numbers" do + test "negative integer as standalone" do + assert read_one!("-3") == -3 + end + + test "negative float as standalone" do + assert read_one!("-3.14") == -3.14 + end + + test "negative numbers inside list" do + {:list, _, elems} = read_one!("(-3 -4)") + assert elems == [-3, -4] + end + + test "subtraction symbol followed by space and number" do + {:list, _, [sym, num]} = read_one!("(- 3)") + assert sym == {:symbol, %{line: 1, col: 2}, "-"} + assert num == 3 + end + + test "negative number after symbol in list" do + {:list, _, [sym, num]} = read_one!("(x -3)") + assert sym == {:symbol, %{line: 1, col: 2}, "x"} + assert num == -3 + end + + test "negative number in vector" do + {:vector, _, elems} = read_one!("[-1 -2 -3]") + assert elems == [-1, -2, -3] + end + end + + describe "keywords with special chars" do + test "keyword with hyphen" do + assert read_one!(":my-key") == :"my-key" + end + + test "keyword with question mark" do + assert read_one!(":valid?") == :valid? + end + + test "keyword with dot" do + assert read_one!(":some.ns") == :"some.ns" + end + end + + describe "empty collections" do + test "empty list" do + assert read_one!("()") == {:list, %{line: 1, col: 1}, []} + end + + test "empty vector" do + assert read_one!("[]") == {:vector, %{line: 1, col: 1}, []} + end + + test "empty map" do + assert read_one!("{}") == {:map, %{line: 1, col: 1}, []} + end + + test "empty set" do + assert read_one!("\#{}") == {:set, %{line: 1, col: 1}, []} + end + + test "empty tuple" do + assert read_one!("#el[]") == {:tuple, %{line: 1, col: 1}, []} + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Error cases + # ═══════════════════════════════════════════════════════════════════ + + describe "error cases" do + test "unclosed list" do + assert {:error, msg} = Reader.read_string("(1 2 3") + assert msg =~ "expected ')'" + end + + test "unclosed vector" do + assert {:error, msg} = Reader.read_string("[1 2 3") + assert msg =~ "expected ']'" + end + + test "unclosed map" do + assert {:error, msg} = Reader.read_string("{:a 1") + assert msg =~ "expected '}'" + end + + test "unclosed set" do + assert {:error, msg} = Reader.read_string("\#{:a :b") + assert msg =~ "expected '}'" + end + + test "unclosed string" do + assert {:error, msg} = Reader.read_string(~s("hello)) + assert msg =~ "Unterminated string" + end + + test "unclosed tuple" do + assert {:error, msg} = Reader.read_string("#el[:ok") + assert msg =~ "expected ']'" + end + + test "unexpected closing paren" do + assert {:error, _msg} = Reader.read_string(")") + end + + test "unexpected closing bracket" do + assert {:error, _msg} = Reader.read_string("]") + end + + test "unexpected closing brace" do + assert {:error, _msg} = Reader.read_string("}") + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Multi-form parsing + # ═══════════════════════════════════════════════════════════════════ + + describe "multi-form parsing" do + test "multiple top-level forms" do + forms = read!("1 2 3") + assert forms == [1, 2, 3] + end + + test "multiple forms of different types" do + forms = read!(":ok 42 \"hello\" true nil") + assert forms == [:ok, 42, "hello", true, nil] + end + + test "multiple lists" do + forms = read!("(+ 1 2) (* 3 4)") + assert length(forms) == 2 + assert {:list, _, _} = Enum.at(forms, 0) + assert {:list, _, _} = Enum.at(forms, 1) + end + + test "forms separated by newlines" do + forms = read!("1\n2\n3") + assert forms == [1, 2, 3] + end + + test "empty input" do + assert read!("") == [] + end + + test "only whitespace" do + assert read!(" \n\t ") == [] + end + + test "only comments" do + assert read!("; just a comment\n; another comment") == [] + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Line and column tracking + # ═══════════════════════════════════════════════════════════════════ + + describe "line and column tracking" do + test "first form at line 1, col 1" do + {:symbol, meta, _} = read_one!("hello") + assert meta == %{line: 1, col: 1} + end + + test "form after newline tracks correct line" do + [_, {:symbol, meta, _}] = read!("foo\nbar") + assert meta.line == 2 + assert meta.col == 1 + end + + test "form after comment tracks correct line" do + [form] = read!("; comment\nhello") + assert {:symbol, %{line: 2, col: 1}, "hello"} = form + end + + test "elements inside collection track position" do + {:list, _, [_, second, _]} = read_one!("(a b c)") + assert {:symbol, %{line: 1, col: 4}, "b"} = second + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Tokenizer-specific edge cases + # ═══════════════════════════════════════════════════════════════════ + + describe "tokenizer edge cases" do + test "dispatch #el[ is recognized as tuple start" do + {:tuple, _, [:ok]} = read_one!("#el[:ok]") + end + + test "#el[ does not consume extra chars" do + {:tuple, _, [num]} = read_one!("#el[42]") + assert num == 42 + end + + test "hash dispatch for set vs tuple vs anon fn" do + assert {:set, _, _} = read_one!("\#{1 2}") + assert {:tuple, _, _} = read_one!("#el[1 2]") + assert {:anon_fn, _, _} = read_one!("#(+ 1 2)") + assert {:regex, _, _} = read_one!(~s(#"abc")) + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Real-world: ChatRoom from the spec + # ═══════════════════════════════════════════════════════════════════ + + describe "ChatRoom example" do + test "parses the ChatRoom defmodule" do + source = """ + (defmodule ChatRoom + + (defn loop [state] + (receive + [:join username pid] + (let [members (assoc (:members state) username pid)] + (send pid [:welcome username (count members)]) + (loop (assoc state :members members))) + + [:message from body] + (do + (doseq [[_name pid] (:members state)] + (send pid [:chat from body])) + (loop state)) + + [:leave username] + (loop (update state :members dissoc username)) + + :shutdown + (do + (doseq [[_name pid] (:members state)] + (send pid :room-closed)) + :ok)))) + """ + + {:ok, [form]} = Reader.read_string(source) + assert {:list, _, [defmod_sym, chatroom_sym | body]} = form + assert {:symbol, _, "defmodule"} = defmod_sym + assert {:symbol, _, "ChatRoom"} = chatroom_sym + + # The body should contain the defn form + [defn_form] = body + assert {:list, _, [defn_sym, loop_sym, params | _rest_body]} = defn_form + assert {:symbol, _, "defn"} = defn_sym + assert {:symbol, _, "loop"} = loop_sym + assert {:vector, _, [{:symbol, _, "state"}]} = params + end + + test "parses ChatRoom usage" do + source = """ + (def room (spawn (fn [] (ChatRoom/loop {:owner "alice" :members {}})))) + (send room [:join "alice" *self*]) + (send room [:join "bob" *self*]) + (send room [:message "bob" "hey everyone"]) + """ + + {:ok, forms} = Reader.read_string(source) + assert length(forms) == 4 + + # First form: (def room ...) + [def_form | _] = forms + assert {:list, _, [{:symbol, _, "def"}, {:symbol, _, "room"}, spawn_call]} = def_form + assert {:list, _, [{:symbol, _, "spawn"}, _fn_form]} = spawn_call + + # Last form: (send room [:message ...]) + last = List.last(forms) + assert {:list, _, [{:symbol, _, "send"}, {:symbol, _, "room"}, msg_vec]} = last + assert {:vector, _, [:message, "bob", "hey everyone"]} = msg_vec + end + end + + # ═══════════════════════════════════════════════════════════════════ + # Complex real-world patterns + # ═══════════════════════════════════════════════════════════════════ + + describe "complex forms" do + test "defn with multiple clauses" do + source = "(defn greet ([name] (greet name \"hello\")) ([name greeting] (str greeting \" \" name)))" + {:ok, [form]} = Reader.read_string(source) + assert {:list, _, [{:symbol, _, "defn"}, {:symbol, _, "greet"} | clauses]} = form + assert length(clauses) == 2 + end + + test "let with destructuring" do + source = "(let [{:keys [name email]} user] (str name \" <\" email \">\"))" + {:ok, [form]} = Reader.read_string(source) + assert {:list, _, [{:symbol, _, "let"}, {:vector, _, _bindings}, _body]} = form + end + + test "metadata on defmodule" do + source = "(defmodule ^{:author \"Ada\"} Greeter (defn hello [name] (str \"hello \" name)))" + {:ok, [form]} = Reader.read_string(source) + assert {:list, _, [{:symbol, _, "defmodule"}, {:with_meta, _, _}, _greeter | _]} = form + end + + test "cond form" do + source = """ + (cond + (< x 0) "negative" + (= x 0) "zero" + :else "positive") + """ + + {:ok, [form]} = Reader.read_string(source) + assert {:list, _, [{:symbol, _, "cond"} | clauses]} = form + # 6 elements: 3 test/result pairs + assert length(clauses) == 6 + end + + test "quasiquote with unquote and splice-unquote" do + source = "`(defn ~name [~@args] ~@body)" + {:ok, [form]} = Reader.read_string(source) + assert {:quasiquote, _, {:list, _, elements}} = form + assert {:symbol, _, "defn"} = Enum.at(elements, 0) + assert {:unquote, _, {:symbol, _, "name"}} = Enum.at(elements, 1) + end + + test "nested tuples and sets" do + source = "#el[:ok \#{:a :b}]" + {:ok, [form]} = Reader.read_string(source) + assert {:tuple, _, [:ok, {:set, _, [:a, :b]}]} = form + end + end +end diff --git a/test/clj_elixir/transformer_test.exs b/test/clj_elixir/transformer_test.exs new file mode 100644 index 0000000..c4ddecf --- /dev/null +++ b/test/clj_elixir/transformer_test.exs @@ -0,0 +1,1400 @@ +defmodule CljElixir.TransformerTest do + use ExUnit.Case, async: true + + alias CljElixir.Transformer + alias CljElixir.Transformer.Context + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + # Parse CljElixir source, transform, return Elixir AST + defp transform(source) do + {:ok, forms} = CljElixir.Reader.read_string(source) + Transformer.transform(forms) + end + + # Parse, transform, eval, return result + # Uses vector_as_list: true until PersistentVector is implemented (Phase 3 WS-3) + defp eval(source) do + {:ok, result, _bindings} = CljElixir.Compiler.eval_string(source, vector_as_list: true) + result + end + + # Generate a unique module name string and its Elixir module atom + defp unique_mod(prefix) do + n = System.unique_integer([:positive]) + name_str = "#{prefix}#{n}" + mod_atom = String.to_atom("Elixir.#{name_str}") + {name_str, mod_atom} + end + + # --------------------------------------------------------------------------- + # 1. defmodule + # --------------------------------------------------------------------------- + + describe "defmodule" do + test "basic module definition" do + {name, mod} = unique_mod("TfTestMod") + source = "(defmodule #{name} (defn hello [] :world))" + eval(source) + assert apply(mod, :hello, []) == :world + end + + test "module with docstring" do + ast = transform(~S|(defmodule MyMod "A test module" (def x 1))|) + ast_str = Macro.to_string(ast) + assert ast_str =~ "moduledoc" + end + + test "module with multiple defs" do + {name, mod} = unique_mod("TfTestMultiDef") + + source = """ + (defmodule #{name} + (defn add [x y] (+ x y)) + (defn sub [x y] (- x y))) + """ + + eval(source) + assert apply(mod, :add, [3, 4]) == 7 + assert apply(mod, :sub, [10, 3]) == 7 + end + end + + # --------------------------------------------------------------------------- + # 2. defn / defn- + # --------------------------------------------------------------------------- + + describe "defn" do + test "single-arity function" do + {name, mod} = unique_mod("TfTestDefn1") + + source = """ + (defmodule #{name} + (defn double [x] (* x 2))) + """ + + eval(source) + assert apply(mod, :double, [5]) == 10 + end + + test "multi-clause function" do + {name, mod} = unique_mod("TfTestDefn2") + + source = """ + (defmodule #{name} + (defn greet + ([name] (str "hello " name)) + ([name greeting] (str greeting " " name)))) + """ + + eval(source) + assert apply(mod, :greet, ["Ada"]) == "hello Ada" + assert apply(mod, :greet, ["Ada", "hi"]) == "hi Ada" + end + + test "defn with docstring" do + ast = transform(~S|(defn hello "Greets someone" [name] (str "hi " name))|) + ast_str = Macro.to_string(ast) + assert ast_str =~ "doc" + end + + test "defn- produces private function" do + ast = transform("(defn- helper [x] (* x 2))") + ast_str = Macro.to_string(ast) + assert ast_str =~ "defp" + end + + test "defn with multiple body expressions" do + {name, mod} = unique_mod("TfTestDefnBody") + + source = """ + (defmodule #{name} + (defn process [x] + (+ x 1) + (* x 2))) + """ + + eval(source) + # Last expression is returned + assert apply(mod, :process, [5]) == 10 + end + end + + # --------------------------------------------------------------------------- + # 3. fn (anonymous functions) + # --------------------------------------------------------------------------- + + describe "fn" do + test "single-arity anonymous function" do + assert eval("((fn [x] (* x x)) 5)") == 25 + end + + test "multi-clause same-arity anonymous function" do + # Elixir anonymous functions support multiple clauses of same arity + result = eval("((fn ([0] :zero) ([x] x)) 42)") + assert result == 42 + end + + test "fn used as argument to HOF" do + result = eval("(Enum/map [1 2 3] (fn [x] (* x x)))") + assert result == [1, 4, 9] + end + + test "fn with two params" do + result = eval("((fn [x y] (+ x y)) 3 4)") + assert result == 7 + end + + test "fn with no params" do + result = eval("((fn [] 42))") + assert result == 42 + end + end + + # --------------------------------------------------------------------------- + # 4. #() anonymous shorthand + # --------------------------------------------------------------------------- + + describe "anonymous fn shorthand" do + test "single arg with %" do + result = eval("(Enum/map [1 2 3] #(* % 2))") + assert result == [2, 4, 6] + end + + test "two args with %1 %2" do + result = eval("(Enum/reduce [1 2 3 4 5] 0 #(+ %1 %2))") + assert result == 15 + end + + test "produces correct arity" do + ast = transform("#(+ %1 %2)") + {:fn, _, [{:->, _, [params, _body]}]} = ast + assert length(params) == 2 + end + end + + # --------------------------------------------------------------------------- + # 5. let + # --------------------------------------------------------------------------- + + describe "let" do + test "basic let binding" do + assert eval("(let [x 1 y 2] (+ x y))") == 3 + end + + test "let with multiple bindings" do + assert eval("(let [a 1 b 2 c 3] (+ a (+ b c)))") == 6 + end + + test "let bindings are sequential" do + assert eval("(let [x 1 y (+ x 1)] y)") == 2 + end + + test "let with expression in binding" do + assert eval("(let [x (* 3 4)] x)") == 12 + end + end + + # --------------------------------------------------------------------------- + # 6. if / when / cond / case / do + # --------------------------------------------------------------------------- + + describe "if" do + test "if with true condition" do + assert eval("(if true :yes :no)") == :yes + end + + test "if with false condition" do + assert eval("(if false :yes :no)") == :no + end + + test "if without else returns nil on false" do + assert eval("(if false :yes)") == nil + end + + test "if with complex condition" do + assert eval("(if (> 5 3) :greater :lesser)") == :greater + end + end + + describe "when" do + test "when with true condition" do + assert eval("(when true :yes)") == :yes + end + + test "when with false condition returns nil" do + assert eval("(when false :yes)") == nil + end + + test "when with multiple body expressions" do + result = eval("(when true 1 2 3)") + assert result == 3 + end + end + + describe "cond" do + test "basic cond" do + assert eval("(cond false :a true :b)") == :b + end + + test "cond with :else" do + assert eval("(cond (> 1 2) :a (< 1 2) :b :else :c)") == :b + end + + test "cond falls through to :else" do + assert eval("(cond false :a false :b :else :c)") == :c + end + end + + describe "case" do + test "basic case" do + assert eval("(case :ok :ok :yes :error :no)") == :yes + end + + test "case with tuple patterns" do + assert eval("(case #el[:ok 42] [:ok val] val [:error _] nil)") == 42 + end + + test "case with wildcard" do + assert eval("(case :unknown :ok :yes _ :default)") == :default + end + end + + describe "do" do + test "do block returns last expression" do + assert eval("(do 1 2 3)") == 3 + end + + test "do with side effects" do + result = eval("(do (+ 1 2) (+ 3 4))") + assert result == 7 + end + end + + # --------------------------------------------------------------------------- + # 7. loop / recur + # --------------------------------------------------------------------------- + + describe "loop/recur" do + test "basic loop" do + result = eval("(loop [i 0 acc 0] (if (>= i 5) acc (recur (inc i) (+ acc i))))") + assert result == 10 + end + + test "factorial via loop/recur" do + result = + eval(""" + (loop [n 5 acc 1] + (if (<= n 1) + acc + (recur (dec n) (* acc n)))) + """) + + assert result == 120 + end + + test "recur in defn" do + {name, mod} = unique_mod("TfTestRecur") + + source = """ + (defmodule #{name} + (defn countdown [n] + (if (<= n 0) + :done + (recur (dec n))))) + """ + + eval(source) + assert apply(mod, :countdown, [5]) == :done + end + end + + # --------------------------------------------------------------------------- + # 8. def (top-level binding) + # --------------------------------------------------------------------------- + + describe "def" do + test "def creates a zero-arity function" do + {name, mod} = unique_mod("TfTestDef") + source = "(defmodule #{name} (def answer 42))" + eval(source) + assert apply(mod, :answer, []) == 42 + end + end + + # --------------------------------------------------------------------------- + # 9. Module/function calls (FFI) + # --------------------------------------------------------------------------- + + describe "module calls" do + test "Elixir module call (uppercase)" do + assert eval("(Enum/map [1 2 3] (fn [x] (* x x)))") == [1, 4, 9] + end + + test "Elixir module call with hyphens in function name" do + # String.split -> String.split (no hyphens, just testing the pattern) + result = eval(~S|(String/split "a-b-c" "-")|) + assert result == ["a", "b", "c"] + end + + test "Erlang module call (lowercase)" do + result = eval("(erlang/system_time)") + assert is_integer(result) + end + + test "Map module call" do + result = eval("(Map/put {:a 1} :b 2)") + assert result == %{a: 1, b: 2} + end + + test "Enum/reduce" do + result = eval("(Enum/reduce [1 2 3] 0 (fn [acc x] (+ acc x)))") + assert result == 6 + end + end + + # --------------------------------------------------------------------------- + # 10. Unqualified function calls + # --------------------------------------------------------------------------- + + describe "unqualified calls" do + test "hyphen to underscore conversion" do + ast = transform("(my-func arg1)") + ast_str = Macro.to_string(ast) + assert ast_str =~ "my_func" + end + + test "calling Kernel functions" do + assert eval("(length [1 2 3])") == 3 + end + + test "calling rem" do + assert eval("(rem 10 3)") == 1 + end + end + + # --------------------------------------------------------------------------- + # 11. Data literals + # --------------------------------------------------------------------------- + + describe "data literals" do + test "map literal" do + assert eval("{:a 1 :b 2}") == %{a: 1, b: 2} + end + + test "empty map" do + assert eval("{}") == %{} + end + + test "vector in value position becomes list" do + assert eval("[1 2 3]") == [1, 2, 3] + end + + test "vector in pattern position becomes tuple match" do + assert eval("(case #el[:ok 42] [:ok x] x)") == 42 + end + + test "tuple literal" do + assert eval("#el[:ok 42]") == {:ok, 42} + end + + test "tuple with three elements" do + assert eval("#el[:a :b :c]") == {:a, :b, :c} + end + + test "set literal" do + result = eval(~S|#{:a :b :c}|) + assert result == MapSet.new([:a, :b, :c]) + end + + test "nested data structures" do + result = eval(~S|{:users [{:name "Ada"} {:name "Bob"}]}|) + assert result == %{users: [%{name: "Ada"}, %{name: "Bob"}]} + end + + test "quoted list" do + result = eval("(quote (1 2 3))") + assert result == [1, 2, 3] + end + + test "regex literal" do + ast = transform(~S|#"^\d+$"|) + ast_str = Macro.to_string(ast) + # Macro.to_string renders sigil_r as ~r/pattern/ + assert ast_str =~ "~r" or ast_str =~ "sigil_r" + end + end + + # --------------------------------------------------------------------------- + # 12. Keyword-as-function + # --------------------------------------------------------------------------- + + describe "keyword-as-function" do + test "keyword access on map" do + assert eval(~S|(:name {:name "Ada" :age 30})|) == "Ada" + end + + test "keyword access with default" do + assert eval(~S|(:missing {:name "Ada"} :default)|) == :default + end + + test "keyword access returns nil on missing key" do + assert eval(~S|(:missing {:name "Ada"})|) == nil + end + end + + # --------------------------------------------------------------------------- + # 13. defprotocol + # --------------------------------------------------------------------------- + + describe "defprotocol" do + test "basic protocol definition" do + ast = + transform(""" + (defprotocol Describable + (describe [value])) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "defprotocol" + assert ast_str =~ "describe" + end + + test "protocol with docstring" do + ast = + transform(""" + (defprotocol Describable + "Protocol for descriptions" + (describe [value])) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "defprotocol" + assert ast_str =~ "moduledoc" + end + end + + # --------------------------------------------------------------------------- + # 14. defrecord + # --------------------------------------------------------------------------- + + describe "defrecord" do + test "basic record definition" do + ast = + transform(""" + (defrecord User [name age]) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "defstruct" + assert ast_str =~ "defmodule" + end + + test "record with docstring" do + ast = + transform(""" + (defrecord User "A user" [name age]) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "defstruct" + assert ast_str =~ "moduledoc" + end + end + + # --------------------------------------------------------------------------- + # 15. extend-type / extend-protocol + # --------------------------------------------------------------------------- + + describe "extend-type" do + test "produces defimpl" do + ast = + transform(""" + (extend-type Map + MyProto + (-lookup [m k] (Map/get m k))) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "defimpl" + assert ast_str =~ "MyProto" + assert ast_str =~ "Map" + end + end + + describe "extend-protocol" do + test "produces defimpl" do + ast = + transform(""" + (extend-protocol MyProto + Map + (-lookup [m k] (Map/get m k))) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "defimpl" + end + end + + # --------------------------------------------------------------------------- + # 16. reify + # --------------------------------------------------------------------------- + + describe "reify" do + test "produces defmodule and struct instance" do + ast = + transform(""" + (reify + MyProto + (-describe [_] "hello")) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "defmodule" + assert ast_str =~ "defstruct" + end + end + + # --------------------------------------------------------------------------- + # 17. with + # --------------------------------------------------------------------------- + + describe "with" do + test "basic with" do + result = + eval(""" + (with [[:ok x] #el[:ok 42]] + x) + """) + + assert result == 42 + end + + test "with multiple bindings" do + result = + eval(""" + (with [[:ok a] #el[:ok 1] + [:ok b] #el[:ok 2]] + (+ a b)) + """) + + assert result == 3 + end + + test "with short-circuit on mismatch" do + result = + eval(""" + (with [[:ok x] #el[:error :oops]] + x) + """) + + assert result == {:error, :oops} + end + end + + # --------------------------------------------------------------------------- + # 18. receive + # --------------------------------------------------------------------------- + + describe "receive" do + test "produces receive AST" do + ast = + transform(""" + (receive + [:ok val] val + [:error _] nil) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "receive" + end + + test "receive with after" do + ast = + transform(""" + (receive + [:ok val] val + :after 1000 :timeout) + """) + + ast_str = Macro.to_string(ast) + assert ast_str =~ "receive" + assert ast_str =~ "after" + end + + test "receive with timeout evaluates" do + # Send ourselves a message, then receive it + result = + eval(""" + (do + (Kernel/send *self* #el[:ok 42]) + (receive + [:ok val] val + :after 100 :timeout)) + """) + + assert result == 42 + end + + test "receive timeout fires when no message" do + result = + eval(""" + (receive + [:ok val] val + :after 1 :timeout) + """) + + assert result == :timeout + end + end + + # --------------------------------------------------------------------------- + # 18b. send/spawn/spawn-link (bare unqualified calls) + # --------------------------------------------------------------------------- + + describe "send/spawn/spawn-link" do + test "send delivers message to self" do + result = + eval(""" + (do + (send *self* :hello) + (receive + :hello :got-it + :after 100 :timeout)) + """) + + assert result == :"got-it" + end + + test "send with tuple message" do + result = + eval(""" + (do + (send *self* #el[:ok 42]) + (receive + [:ok val] val + :after 100 :timeout)) + """) + + assert result == 42 + end + + test "spawn creates a process" do + result = + eval(""" + (do + (let [pid (spawn (fn [] :done))] + (is-pid pid))) + """) + + assert result == true + end + + test "spawn and send between processes" do + result = + eval(""" + (do + (let [parent *self* + child (spawn (fn [] + (send parent #el[:from-child 99]) + :done))] + (receive + [:from-child val] val + :after 1000 :timeout))) + """) + + assert result == 99 + end + + test "spawn-link creates a linked process" do + result = + eval(""" + (do + (let [pid (spawn-link (fn [] :done))] + (is-pid pid))) + """) + + assert result == true + end + + test "spawn with module/function/args" do + # spawn/3 with module, function, args + ast = transform("(spawn Kernel :is-integer '(42))") + ast_str = Macro.to_string(ast) + assert ast_str =~ "spawn" + end + end + + # --------------------------------------------------------------------------- + # 18c. Process primitives: monitor, link, unlink, alive? + # --------------------------------------------------------------------------- + + describe "process primitives" do + test "link/unlink" do + result = + eval(""" + (do + (let [pid (spawn (fn [] (receive _ :ok :after 1000 :done)))] + (link pid) + (unlink pid) + (alive? pid))) + """) + + assert result == true + end + + test "monitor produces reference" do + result = + eval(""" + (do + (let [pid (spawn (fn [] (receive _ :ok :after 1000 :done))) + ref (monitor pid)] + (is-reference ref))) + """) + + assert result == true + end + + test "monitor with type" do + result = + eval(""" + (do + (let [pid (spawn (fn [] :done)) + ref (monitor :process pid)] + (is-reference ref))) + """) + + assert result == true + end + + test "alive? returns boolean" do + result = + eval(""" + (do + (let [pid (spawn (fn [] :done))] + (Process/sleep 50) + (alive? pid))) + """) + + assert result == false + end + end + + # --------------------------------------------------------------------------- + # 19. for / doseq + # --------------------------------------------------------------------------- + + describe "for" do + test "basic for comprehension" do + result = eval("(for [x [1 2 3]] (* x x))") + assert result == [1, 4, 9] + end + + test "for with :when filter" do + result = eval("(for [x [1 2 3 4 5] :when (> x 2)] x)") + assert result == [3, 4, 5] + end + + test "for with multiple generators" do + result = eval("(for [x [1 2] y [3 4]] (+ x y))") + assert result == [4, 5, 5, 6] + end + end + + describe "doseq" do + test "doseq produces for AST" do + ast = transform("(doseq [x [1 2 3]] (println x))") + ast_str = Macro.to_string(ast) + assert ast_str =~ "for" + end + end + + # --------------------------------------------------------------------------- + # 20. if-let / when-let / if-some / when-some + # --------------------------------------------------------------------------- + + describe "if-let" do + test "if-let with truthy value" do + result = eval("(if-let [x 42] x :nope)") + assert result == 42 + end + + test "if-let with nil" do + result = eval("(if-let [x nil] x :nope)") + assert result == :nope + end + + test "if-let with false" do + result = eval("(if-let [x false] x :nope)") + assert result == :nope + end + end + + describe "when-let" do + test "when-let with truthy value" do + result = eval("(when-let [x 42] (+ x 1))") + assert result == 43 + end + + test "when-let with nil returns nil" do + result = eval("(when-let [x nil] (+ x 1))") + assert result == nil + end + end + + describe "if-some" do + test "if-some with non-nil value" do + result = eval("(if-some [x 42] x :nope)") + assert result == 42 + end + + test "if-some with false (not nil)" do + result = eval("(if-some [x false] x :nope)") + assert result == false + end + + test "if-some with nil" do + result = eval("(if-some [x nil] x :nope)") + assert result == :nope + end + end + + describe "when-some" do + test "when-some with non-nil value" do + result = eval("(when-some [x 42] (+ x 1))") + assert result == 43 + end + + test "when-some with nil returns nil" do + result = eval("(when-some [x nil] (+ x 1))") + assert result == nil + end + + test "when-some with false (non-nil)" do + # false is not nil, so the body should execute + # Keywords preserve hyphens: :got-it stays as :got-it + result = eval("(when-some [x false] :done)") + assert result == :done + end + end + + # --------------------------------------------------------------------------- + # 21. use / require / import / alias + # --------------------------------------------------------------------------- + + describe "directives" do + test "use produces use AST" do + ast = transform("(use GenServer)") + ast_str = Macro.to_string(ast) + assert ast_str =~ "use" + assert ast_str =~ "GenServer" + end + + test "require produces require AST" do + ast = transform("(require Logger)") + ast_str = Macro.to_string(ast) + assert ast_str =~ "require" + assert ast_str =~ "Logger" + end + + test "import produces import AST" do + ast = transform("(import Enum)") + ast_str = Macro.to_string(ast) + assert ast_str =~ "import" + assert ast_str =~ "Enum" + end + + test "alias produces alias AST" do + ast = transform("(alias MyApp)") + ast_str = Macro.to_string(ast) + assert ast_str =~ "alias" + assert ast_str =~ "MyApp" + end + end + + # --------------------------------------------------------------------------- + # 22. Operators and builtins + # --------------------------------------------------------------------------- + + describe "arithmetic operators" do + test "addition" do + assert eval("(+ 1 2)") == 3 + end + + test "addition variadic" do + assert eval("(+ 1 2 3 4)") == 10 + end + + test "subtraction" do + assert eval("(- 10 3)") == 7 + end + + test "unary minus" do + assert eval("(- 5)") == -5 + end + + test "multiplication" do + assert eval("(* 3 4)") == 12 + end + + test "multiplication variadic" do + assert eval("(* 2 3 4)") == 24 + end + + # Note: / is not a valid symbol start char in the reader, + # so division uses the Kernel module call instead + test "division via Kernel" do + result = eval("(Kernel/div 10 2)") + assert result == 5 + end + end + + describe "comparison operators" do + test "greater than" do + assert eval("(> 5 3)") == true + assert eval("(> 3 5)") == false + end + + test "less than" do + assert eval("(< 3 5)") == true + assert eval("(< 5 3)") == false + end + + test "greater than or equal" do + assert eval("(>= 5 5)") == true + assert eval("(>= 5 6)") == false + end + + test "less than or equal" do + assert eval("(<= 5 5)") == true + assert eval("(<= 6 5)") == false + end + end + + describe "equality" do + test "= for value equality" do + assert eval("(= 1 1)") == true + assert eval("(= 1 2)") == false + end + + test "== for numeric equality" do + assert eval("(== 1 1)") == true + end + + test "not=" do + assert eval("(not= 1 2)") == true + assert eval("(not= 1 1)") == false + end + + test "!=" do + assert eval("(!= 1 2)") == true + assert eval("(!= 1 1)") == false + end + end + + describe "boolean operators" do + test "not" do + assert eval("(not true)") == false + assert eval("(not false)") == true + end + + test "and" do + assert eval("(and true true)") == true + assert eval("(and true false)") == false + end + + test "or" do + assert eval("(or false true)") == true + assert eval("(or false false)") == false + end + + test "and variadic" do + assert eval("(and true true true)") == true + assert eval("(and true false true)") == false + end + end + + describe "builtins" do + test "inc" do + assert eval("(inc 5)") == 6 + end + + test "dec" do + assert eval("(dec 5)") == 4 + end + + test "str concatenation" do + assert eval(~S|(str "hello" " " "world")|) == "hello world" + end + + test "str single arg" do + assert eval(~S|(str 42)|) == "42" + end + + test "str empty" do + assert eval("(str)") == "" + end + + test "nil?" do + assert eval("(nil? nil)") == true + assert eval("(nil? 42)") == false + end + + test "count" do + assert eval("(count [1 2 3])") == 3 + end + + test "hd" do + assert eval("(hd [1 2 3])") == 1 + end + + test "tl" do + assert eval("(tl [1 2 3])") == [2, 3] + end + + test "cons" do + assert eval("(cons 0 [1 2 3])") == [0, 1, 2, 3] + end + + test "throw produces raise" do + ast = transform(~S|(throw "oops")|) + ast_str = Macro.to_string(ast) + assert ast_str =~ "raise" + end + + test "println produces IO.puts" do + ast = transform(~S|(println "hello")|) + ast_str = Macro.to_string(ast) + assert ast_str =~ "IO" + assert ast_str =~ "puts" + end + end + + # --------------------------------------------------------------------------- + # 23. Dynamic vars + # --------------------------------------------------------------------------- + + describe "dynamic vars" do + test "*self* produces self() call" do + ast = transform("*self*") + assert ast == {:self, [], []} + end + + test "*node* produces node() call" do + ast = transform("*node*") + assert ast == {:node, [], []} + end + + test "*self* evaluates to current process" do + result = eval("*self*") + assert is_pid(result) + end + end + + # --------------------------------------------------------------------------- + # 24. munge_name + # --------------------------------------------------------------------------- + + describe "munge_name" do + test "hyphens to underscores" do + assert Transformer.munge_name("my-func") == "my_func" + end + + test "question mark to _qmark" do + assert Transformer.munge_name("nil?") == "nil_qmark" + end + + test "exclamation to _bang" do + assert Transformer.munge_name("reset!") == "reset_bang" + end + + test "combined hyphen and bang" do + assert Transformer.munge_name("do-thing!") == "do_thing_bang" + end + + test "no change for plain names" do + assert Transformer.munge_name("hello") == "hello" + end + + test "arrow preserved" do + # -> contains a hyphen, but it's part of the arrow + assert Transformer.munge_name("map->set") == "map_>set" + end + end + + # --------------------------------------------------------------------------- + # 25. Symbols as variables + # --------------------------------------------------------------------------- + + describe "symbols" do + test "plain symbol becomes variable" do + ast = transform("x") + assert ast == {:x, [], nil} + end + + test "symbol with hyphens becomes munged variable" do + ast = transform("my-var") + assert ast == {:my_var, [], nil} + end + + test "true symbol becomes true literal" do + assert eval("true") == true + end + + test "false symbol becomes false literal" do + assert eval("false") == false + end + + test "nil symbol becomes nil literal" do + assert eval("nil") == nil + end + end + + # --------------------------------------------------------------------------- + # 26. Pattern position vectors -> tuple matches + # --------------------------------------------------------------------------- + + describe "pattern position vectors" do + test "vector in case pattern becomes tuple" do + result = eval("(case #el[:ok 42] [:ok x] x [:error _] nil)") + assert result == 42 + end + + test "vector in let LHS becomes tuple match" do + result = eval("(let [[:ok x] #el[:ok 99]] x)") + assert result == 99 + end + + test "nested vector patterns in case" do + result = + eval(""" + (case #el[:ok #el[:inner 5]] + [:ok [:inner n]] n + _ nil) + """) + + assert result == 5 + end + + test "defn params are not in pattern context (they are parameter lists)" do + # Params in defn are parameter lists, not patterns for tuple matching + # The params vector itself is parameter list, but inner vectors would be patterns + {name, mod} = unique_mod("TfTestPatParams") + + source = """ + (defmodule #{name} + (defn extract [msg] + (case msg + [:ok val] val + _ nil))) + """ + + eval(source) + assert apply(mod, :extract, [{:ok, 42}]) == 42 + end + end + + # --------------------------------------------------------------------------- + # Integration: end-to-end tests + # --------------------------------------------------------------------------- + + describe "integration" do + test "fibonacci with loop/recur" do + result = + eval(""" + (loop [n 10 a 0 b 1] + (if (= n 0) + a + (recur (dec n) b (+ a b)))) + """) + + assert result == 55 + end + + test "map + filter pipeline" do + result = + eval(""" + (Enum/filter + (Enum/map [1 2 3 4 5 6 7 8 9 10] + (fn [x] (* x x))) + (fn [x] (> x 25))) + """) + + assert result == [36, 49, 64, 81, 100] + end + + test "nested let with function calls" do + result = + eval(""" + (let [nums [1 2 3 4 5] + doubled (Enum/map nums (fn [x] (* x 2))) + total (Enum/reduce doubled 0 (fn [acc x] (+ acc x)))] + total) + """) + + assert result == 30 + end + + test "defmodule with multiple features" do + {name, mod} = unique_mod("TfTestIntegration") + + source = """ + (defmodule #{name} + (defn factorial [n] + (loop [i n acc 1] + (if (<= i 1) + acc + (recur (dec i) (* acc i))))) + + (defn sum-squares [nums] + (Enum/reduce + (Enum/map nums (fn [x] (* x x))) + 0 + (fn [acc x] (+ acc x))))) + """ + + eval(source) + assert apply(mod, :factorial, [5]) == 120 + assert apply(mod, :sum_squares, [[1, 2, 3, 4, 5]]) == 55 + end + + test "keyword access chain" do + result = + eval(""" + (let [person {:name "Ada" :age 30}] + (str (:name person) " is " (Kernel/to_string (:age person)))) + """) + + assert result == "Ada is 30" + end + + test "for comprehension with filter" do + result = + eval(""" + (for [x [1 2 3 4 5 6 7 8 9 10] + :when (= 0 (rem x 2))] + (* x x)) + """) + + assert result == [4, 16, 36, 64, 100] + end + + test "case with multiple patterns in module" do + {name, mod} = unique_mod("TfTestCase") + + source = """ + (defmodule #{name} + (defn handle [msg] + (case msg + [:ok data] (str "ok: " (Kernel/to_string data)) + [:error reason] (str "error: " (Kernel/to_string reason)) + _ "unknown"))) + """ + + eval(source) + assert apply(mod, :handle, [{:ok, 42}]) == "ok: 42" + assert apply(mod, :handle, [{:error, :bad}]) == "error: bad" + assert apply(mod, :handle, [:other]) == "unknown" + end + + test "with chain" do + result = + eval(""" + (with [[:ok a] #el[:ok 1] + [:ok b] #el[:ok 2] + [:ok c] #el[:ok 3]] + (+ a (+ b c))) + """) + + assert result == 6 + end + + test "with chain short-circuits" do + result = + eval(""" + (with [[:ok a] #el[:ok 1] + [:ok b] #el[:error :fail] + [:ok c] #el[:ok 3]] + (+ a (+ b c))) + """) + + assert result == {:error, :fail} + end + + test "anonymous function as value" do + result = + eval(""" + (let [f (fn [x] (* x x))] + (Enum/map [1 2 3] f)) + """) + + assert result == [1, 4, 9] + end + + test "nested cond" do + result = + eval(""" + (let [x 15] + (cond + (> x 20) :high + (> x 10) :medium + :else :low)) + """) + + assert result == :medium + end + end + + # --------------------------------------------------------------------------- + # Literals passthrough + # --------------------------------------------------------------------------- + + describe "literals" do + test "integers" do + assert eval("42") == 42 + assert eval("-7") == -7 + end + + test "floats" do + assert eval("3.14") == 3.14 + end + + test "strings" do + assert eval(~S|"hello"|) == "hello" + end + + test "keywords" do + assert eval(":ok") == :ok + assert eval(":error") == :error + end + + test "booleans" do + assert eval("true") == true + assert eval("false") == false + end + + test "nil" do + assert eval("nil") == nil + end + end + + # --------------------------------------------------------------------------- + # Context struct + # --------------------------------------------------------------------------- + + describe "Context" do + test "default context" do + ctx = %Context{} + assert ctx.module_name == nil + assert ctx.function_name == nil + assert ctx.function_arity == nil + assert ctx.loop_var == nil + assert ctx.loop_arity == nil + assert ctx.in_pattern == false + assert ctx.records == %{} + assert ctx.gensym_counter == 0 + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()