Files
CljElixir/lib/mix/tasks/compile.clj_elixir.ex
Adam d8719b6d48 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 <noreply@anthropic.com>
2026-03-08 10:38:22 -04:00

337 lines
9.1 KiB
Elixir

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