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