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>
337 lines
9.1 KiB
Elixir
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
|