defmodule Mix.Tasks.Firebird.Target.Compile do @moduledoc """ Compile Elixir project to WebAssembly via the Firebird WASM target. This is the primary entry point for compiling Elixir modules to WASM. It integrates with Mix's compilation pipeline and supports incremental builds, parallel compilation, and detailed diagnostics. ## Usage # Compile all @wasm annotated modules mix firebird.target.compile # Compile specific files mix firebird.target.compile --files lib/math.ex,lib/crypto.ex # Compile with optimizations mix firebird.target.compile --optimize --tco # Compile with LICM (loop-invariant code motion, requires --optimize --tco) mix firebird.target.compile --optimize --tco --licm # WAT only (no binary) mix firebird.target.compile --wat-only # Force full recompilation mix firebird.target.compile --force # Show diagnostics mix firebird.target.compile --diagnostics # Emit compilation manifest mix firebird.target.compile --manifest ## MIX_TARGET Integration Set `MIX_TARGET=wasm` to automatically trigger WASM compilation: MIX_TARGET=wasm mix compile ## Configuration In `mix.exs`: def project do [ firebird: [ wasm_output: "_build/wasm", wasm_sources: ["lib/wasm_modules"], optimize: true, tco: true ] ] end ## Compilation Diagnostics With `--diagnostics`, the compiler reports: - Unsupported language features found in source files - Type inference warnings - Optimization opportunities - Binary size analysis ## Exit Codes - 0: All modules compiled successfully - 1: One or more modules failed to compile """ use Mix.Task @shortdoc "Compile Elixir modules to WebAssembly" @switches [ output: :string, files: :string, dirs: :string, wat_only: :boolean, verbose: :boolean, verify: :boolean, force: :boolean, optimize: :boolean, tco: :boolean, inline: :boolean, cse: :boolean, licm: :boolean, diagnostics: :boolean, manifest: :boolean, parallel: :boolean, json: :boolean, quiet: :boolean ] @aliases [ o: :output, f: :files, d: :dirs, v: :verbose, q: :quiet ] @impl Mix.Task def run(args) do {opts, _rest, _invalid} = OptionParser.parse(args, switches: @switches, aliases: @aliases) config = build_config(opts) quiet = Keyword.get(opts, :quiet, false) show_diagnostics = Keyword.get(opts, :diagnostics, false) emit_manifest = Keyword.get(opts, :manifest, false) json_output = Keyword.get(opts, :json, false) unless quiet do Mix.shell().info("đŸ”Ĩ Firebird WASM Target Compiler") Mix.shell().info("") end # Validate prerequisites case validate_environment(config) do :ok -> :ok {:error, message} -> Mix.raise(message) end # Discover sources sources = Firebird.Target.discover_sources(config) if Enum.empty?(sources) do unless quiet do Mix.shell().info(" No compilable sources found.") Mix.shell().info(" Add @wasm true annotations to your modules.") end :ok else # Build compiler opts fingerprint for content-hash staleness detection compiler_opts = [ optimize: config.optimize, tco: config.tco, inline: config.inline, cse: Map.get(config, :cse, false), licm: Map.get(config, :licm, false), wat_only: config.wat_only ] # Filter stale sources unless --force sources_to_compile = if config.force do sources else filter_stale_sources(sources, config.output_dir, compiler_opts) end if Enum.empty?(sources_to_compile) do unless quiet do Mix.shell().info( " All #{length(sources)} file(s) up to date. Use --force to recompile." ) end :ok else # Run diagnostics pre-flight if requested if show_diagnostics do run_diagnostics(sources_to_compile) end # Compile (parallel or sequential) parallel = Keyword.get(opts, :parallel, false) start = System.monotonic_time(:millisecond) results = if parallel do compile_parallel(sources_to_compile, config, quiet) else compile_all(sources_to_compile, config, quiet) end elapsed = System.monotonic_time(:millisecond) - start successes = Enum.filter(results, &match?({:ok, _}, &1)) failures = Enum.filter(results, &match?({:error, _}, &1)) # Emit manifest if requested if emit_manifest do write_compilation_manifest(successes, config, elapsed) end # Report results if json_output do report_json(successes, failures, elapsed) else unless quiet do report_results(successes, failures, elapsed, sources, sources_to_compile) end end # Persist content hashes for successfully compiled sources # so the next build can use accurate content-based staleness detection if Enum.any?(successes) do compiled_sources = Enum.map(successes, fn {:ok, r} -> Map.get(r, :source_file) end) |> Enum.filter(&is_binary/1) if Enum.any?(compiled_sources) do update_content_hashes(compiled_sources, compiler_opts: compiler_opts) end end if Enum.any?(failures) do exit({:shutdown, 1}) end :ok end end end @doc """ Build a Target.Config from CLI options merged with project config. """ @spec build_config(keyword()) :: Firebird.Target.Config.t() def build_config(opts) do base = Firebird.Target.Config.from_mix_project() overrides = opts |> Keyword.take([ :optimize, :tco, :inline, :cse, :licm, :wat_only, :verify, :verbose, :force ]) |> Keyword.merge( case Keyword.get(opts, :output) do nil -> [] out -> [output_dir: out] end ) |> Keyword.merge( case Keyword.get(opts, :dirs) do nil -> [] dirs -> [sources: String.split(dirs, ",", trim: true)] end ) |> Keyword.merge( case Keyword.get(opts, :files) do nil -> [] files -> [files: String.split(files, ",", trim: true), sources: []] end ) Firebird.Target.Config.merge(base, overrides) end @doc """ Validate that the compilation environment is properly set up. """ @spec validate_environment(Firebird.Target.Config.t()) :: :ok | {:error, String.t()} def validate_environment(config) do cond do not config.wat_only and not Firebird.Compiler.wat2wasm_available?() -> {:error, """ wat2wasm not found! Install the WebAssembly Binary Toolkit (wabt): # macOS brew install wabt # Ubuntu/Debian apt-get install wabt # Or use --wat-only to generate WAT text only """} true -> :ok end end @doc """ Filter sources to only those that are stale (content changed since last compile). Uses content hashing for accurate change detection - touching a file without changing its content won't trigger recompilation. Falls back to mtime-based detection if hash manifest is unavailable. """ @spec filter_stale_sources([String.t()], String.t()) :: [String.t()] def filter_stale_sources(sources, output_dir) do filter_stale_sources(sources, output_dir, []) end @doc """ Filter sources with compiler options awareness. Uses content hashing (SHA-256 of file contents + compiler options) as the primary staleness check. Falls back to mtime-based detection when the content hash manifest doesn't exist yet (first build). This means: - Touching a file without changing content does NOT trigger recompilation - Changing compiler flags (e.g. --optimize, --tco) DOES trigger recompilation - First build uses mtime, subsequent builds use content hashes """ @spec filter_stale_sources([String.t()], String.t(), keyword()) :: [String.t()] def filter_stale_sources(sources, output_dir, compiler_opts) do alias Firebird.Target.ContentHash hash_manifest_path = ContentHash.manifest_path() case ContentHash.read_manifest(hash_manifest_path) do {:ok, cached} when map_size(cached) > 0 -> # Content hash manifest exists — use accurate content-based detection ContentHash.filter_stale(sources, cached, compiler_opts) _ -> # No manifest yet (first build) — fall back to mtime filter_stale_by_mtime(sources, output_dir) end end @doc """ Filter sources using content hashing for more accurate change detection. Unlike mtime-based checking, this detects actual content changes, so touching a file without changing it won't trigger recompilation. ## Options - `:hash_manifest` - Path to read/write hash manifest (default: auto) - `:compiler_opts` - Compiler options to include in hash (default: []) """ @spec filter_stale_by_content([String.t()], keyword()) :: [String.t()] def filter_stale_by_content(sources, opts \\ []) do alias Firebird.Target.ContentHash hash_manifest_path = Keyword.get(opts, :hash_manifest, ContentHash.manifest_path()) compiler_opts = Keyword.get(opts, :compiler_opts, []) cached = case ContentHash.read_manifest(hash_manifest_path) do {:ok, m} -> m _ -> %{} end ContentHash.filter_stale(sources, cached, compiler_opts) end @doc """ Update the content hash manifest after successful compilation. """ @spec update_content_hashes([String.t()], keyword()) :: :ok def update_content_hashes(sources, opts \\ []) do alias Firebird.Target.ContentHash hash_manifest_path = Keyword.get(opts, :hash_manifest, ContentHash.manifest_path()) compiler_opts = Keyword.get(opts, :compiler_opts, []) new_hashes = ContentHash.build_manifest(sources, compiler_opts) existing = case ContentHash.read_manifest(hash_manifest_path) do {:ok, m} -> m _ -> %{} end merged = Map.merge(existing, new_hashes) ContentHash.write_manifest(hash_manifest_path, merged) :ok rescue _ -> :ok end defp filter_stale_by_mtime(sources, output_dir) do all_outputs = Path.wildcard(Path.join(output_dir, "*.{wat,wasm}")) Enum.filter(sources, fn source -> source_mtime = get_mtime(source) basename = Path.basename(source, Path.extname(source)) |> String.downcase() output_files = Enum.filter(all_outputs, fn f -> output_base = Path.basename(f) |> String.downcase() String.contains?(output_base, basename) end) if Enum.empty?(output_files) do true else Enum.all?(output_files, fn output -> output_mtime = get_mtime(output) source_mtime > output_mtime end) end end) end @doc """ Run pre-compilation diagnostics on source files. """ @spec run_diagnostics([String.t()]) :: :ok def run_diagnostics(sources) do Mix.shell().info("📋 Pre-compilation Diagnostics") Mix.shell().info("") Enum.each(sources, fn source -> case File.read(source) do {:ok, code} -> diagnostics = analyze_for_diagnostics(code, source) print_diagnostics(source, diagnostics) {:error, _} -> Mix.shell().error(" ❌ Cannot read #{source}") end end) Mix.shell().info("") end # Private defp compile_all(sources, config, quiet) do File.mkdir_p!(config.output_dir) compiler_opts = [ output_dir: config.output_dir, optimize: config.optimize, tco: config.tco, inline: config.inline, cse: Map.get(config, :cse, false), licm: Map.get(config, :licm, false), wat_only: config.wat_only, source_map: config.source_map ] results = Enum.map(sources, fn source -> unless quiet do Mix.shell().info(" 📄 #{Path.relative_to_cwd(source)}") end case Firebird.Compiler.compile(source, compiler_opts) do {:ok, result} -> result = Map.put(result, :source_file, source) unless quiet do Mix.shell().info(" ✅ #{result.module}") if config.verbose do Mix.shell().info(" WAT: #{byte_size(result.wat)} bytes") if result.wasm, do: Mix.shell().info(" WASM: #{byte_size(result.wasm)} bytes") end end if config.verify and result.wasm do verify_module(result, quiet) end {:ok, result} {:error, reason} -> unless quiet do Mix.shell().error(" ❌ #{format_error(reason)}") end {:error, {source, reason}} end end) results end defp compile_parallel(sources, config, quiet) do File.mkdir_p!(config.output_dir) unless quiet do Mix.shell().info(" ⚡ Parallel compilation (#{System.schedulers_online()} workers)") end case Firebird.Target.Parallel.compile(sources, config) do {:ok, results} -> unless quiet do Enum.each(results, fn r -> Mix.shell().info(" ✅ #{r.module}") end) end Enum.map(results, fn r -> {:ok, r} end) {:error, {:partial_failure, details}} -> successes = Keyword.get(details, :successes, []) failures = Keyword.get(details, :failures, []) unless quiet do Enum.each(successes, fn r -> Mix.shell().info(" ✅ #{r.module}") end) Enum.each(failures, fn {:error, {source, reason}} -> Mix.shell().error(" ❌ #{Path.basename(source)}: #{inspect(reason)}") end) end Enum.map(successes, fn r -> {:ok, r} end) ++ failures {:error, reason} -> unless quiet do Mix.shell().error(" ❌ Parallel compilation failed: #{inspect(reason)}") end [{:error, reason}] end end defp verify_module(result, quiet) do case Firebird.load(result.wasm) do {:ok, instance} -> exports = Firebird.exports(instance) unless quiet do Mix.shell().info(" 🔍 Verified: #{length(exports)} export(s)") end Firebird.stop(instance) {:error, reason} -> unless quiet do Mix.shell().error(" ⚠ Verification failed: #{inspect(reason)}") end end end defp report_results(successes, failures, elapsed, all_sources, compiled_sources) do cached = length(all_sources) - length(compiled_sources) Mix.shell().info("") Mix.shell().info("📊 Compilation Summary") Mix.shell().info(" ✅ Compiled: #{length(successes)}") if cached > 0 do Mix.shell().info(" đŸ“Ļ Cached: #{cached}") end if Enum.any?(failures) do Mix.shell().info(" ❌ Failed: #{length(failures)}") end total_wat = successes |> Enum.map(fn {:ok, r} -> byte_size(r.wat) end) |> Enum.sum() total_wasm = successes |> Enum.filter(fn {:ok, r} -> r.wasm != nil end) |> Enum.map(fn {:ok, r} -> byte_size(r.wasm) end) |> Enum.sum() Mix.shell().info(" 📏 Total WAT: #{format_bytes(total_wat)}") if total_wasm > 0 do Mix.shell().info(" đŸ“Ļ Total WASM: #{format_bytes(total_wasm)}") end Mix.shell().info(" ⏱ Time: #{elapsed}ms") Mix.shell().info("") # Print failure details Enum.each(failures, fn {:error, {source, reason}} -> Mix.shell().error(" Failed: #{source}") Mix.shell().error(" #{format_error(reason)}") end) end defp report_json(successes, failures, elapsed) do data = %{ timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), elapsed_ms: elapsed, compiled: length(successes), failed: length(failures), modules: Enum.map(successes, fn {:ok, r} -> %{ module: to_string(r.module), wat_size: byte_size(r.wat), wasm_size: if(r.wasm, do: byte_size(r.wasm), else: nil), source: Map.get(r, :source_file) } end), errors: Enum.map(failures, fn {:error, {source, reason}} -> %{source: source, error: inspect(reason)} end) } Mix.shell().info(Jason.encode!(data, pretty: true)) end defp write_compilation_manifest(successes, config, elapsed) do manifest = %{ version: 1, timestamp: DateTime.utc_now() |> DateTime.to_iso8601(), elapsed_ms: elapsed, output_dir: config.output_dir, options: %{ optimize: config.optimize, tco: config.tco, inline: config.inline, wat_only: config.wat_only }, modules: Enum.map(successes, fn {:ok, r} -> base = Atom.to_string(r.module) |> String.replace(".", "_") %{ module: to_string(r.module), wat_file: "#{base}.wat", wasm_file: if(r.wasm, do: "#{base}.wasm"), wat_size: byte_size(r.wat), wasm_size: if(r.wasm, do: byte_size(r.wasm), else: 0), source: Map.get(r, :source_file) } end) } manifest_path = Path.join(config.output_dir, "firebird_manifest.json") File.mkdir_p!(config.output_dir) File.write!(manifest_path, Jason.encode!(manifest, pretty: true)) Mix.shell().info(" 📋 Manifest: #{manifest_path}") end defp analyze_for_diagnostics(code, source) do diagnostics = %{ warnings: [], info: [], source: source } # Check for unsupported features diagnostics = if String.contains?(code, "spawn") or String.contains?(code, "send") do update_in( diagnostics.warnings, &["Process operations (spawn/send) not supported in WASM" | &1] ) else diagnostics end diagnostics = if String.contains?(code, "IO.") or String.contains?(code, ":io.") do update_in(diagnostics.warnings, &["IO operations not available in WASM" | &1]) else diagnostics end diagnostics = if String.contains?(code, "GenServer") or String.contains?(code, "Agent") do update_in(diagnostics.warnings, &["OTP behaviors not supported in WASM target" | &1]) else diagnostics end diagnostics = if Regex.match?(~r/\[.*\|.*\]/, code) or String.contains?(code, "Enum.") do update_in( diagnostics.warnings, &["List operations not supported - only numeric types available" | &1] ) else diagnostics end diagnostics = if String.contains?(code, "%{") or String.contains?(code, "Map.") do update_in(diagnostics.warnings, &["Map operations not supported in WASM target" | &1]) else diagnostics end diagnostics = if String.contains?(code, "String.") do update_in(diagnostics.warnings, &["String operations not supported in WASM target" | &1]) else diagnostics end # Check for optimization opportunities diagnostics = if Regex.match?(~r/def \w+\(.*\).*\w+\(.*\)\s*\+/, code) do update_in(diagnostics.info, &["Consider --tco flag for tail-recursive functions" | &1]) else diagnostics end # Count functions func_count = Regex.scan(~r/@wasm true/, code) |> length() diagnostics = if func_count > 0 do update_in(diagnostics.info, &["#{func_count} @wasm annotated function(s)" | &1]) else update_in( diagnostics.info, &["No @wasm annotations - all public functions will be compiled" | &1] ) end diagnostics end defp print_diagnostics(source, diagnostics) do basename = Path.relative_to_cwd(source) Mix.shell().info(" 📄 #{basename}") Enum.each(diagnostics.info, fn msg -> Mix.shell().info(" â„šī¸ #{msg}") end) Enum.each(diagnostics.warnings, fn msg -> Mix.shell().info(" âš ī¸ #{msg}") end) Mix.shell().info("") end defp get_mtime(path) do case File.stat(path) do {:ok, %{mtime: mtime}} -> :calendar.datetime_to_gregorian_seconds(mtime) _ -> 0 end end defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B" defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB" defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB" defp format_error({:parse_error, {line, col}, msg, token}) do "Parse error at #{line}:#{col}: #{msg}#{token}" end defp format_error({:validation_errors, errors}) do details = Enum.map(errors, fn {func, errs} -> " #{func}: #{Enum.join(errs, ", ")}" end) "Validation errors:\n#{Enum.join(details, "\n")}" end defp format_error({:wat2wasm_error, output}), do: "wat2wasm: #{output}" defp format_error({:file_error, reason, path}), do: "Cannot read #{path}: #{reason}" defp format_error(other), do: inspect(other) end