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