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>
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
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
|
||||
Reference in New Issue
Block a user