Bootstrap compiler (reader, analyzer, transformer, compiler, Mix plugin), core protocols (16 protocols for Map/List/Tuple/BitString), PersistentVector (bit-partitioned trie), domain tools (clojurify/elixirify), BEAM concurrency (receive, spawn, GenServer), control flow & macros (threading, try/catch, destructuring, defmacro with quasiquote/auto-gensym), and Malli schema adapter (m/=> specs, auto @type, recursive schemas, cross-references). 537 compiler tests + 55 Malli unit tests + 15 integration tests = 607 total. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
313 lines
8.6 KiB
Elixir
313 lines
8.6 KiB
Elixir
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
|