Files
2026-03-09 23:09:46 -04:00

333 lines
9.1 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
eval_ast(ast, opts)
end
end
@doc """
Evaluate pre-parsed CljElixir AST forms.
Runs analyze → transform → eval, skipping the read step.
Used by the REPL for incremental re-evaluation of accumulated definitions.
Returns `{:ok, result, bindings}` on success, or `{:error, diagnostics}` on failure.
"""
@spec eval_forms(list(), keyword()) :: {:ok, term(), keyword()} | {:error, list()}
def eval_forms(forms, opts \\ []) do
with {:ok, forms} <- analyze(forms, opts),
{:ok, ast} <- transform(forms, opts) do
eval_ast(ast, opts)
end
end
defp eval_ast(ast, 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
@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